mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(player): add embedded-mpv player for macOS as experimental feature
- Introduced tooling for building and staging the macOS `libmpv` runtime for IPTVnator's embedded MPV player. - Added `build-macos-runtime.mjs` for building an LGPL-compatible runtime from source. - Created `stage-macos-runtime.mjs` for staging the built runtime artifacts. - Implemented validation for the packaged embedded MPV runtime in `electron-after-pack.cjs` and `embedded-mpv-macos.cjs`. - Updated packaging scripts to ensure the embedded MPV runtime is correctly integrated and validated during the build process. - Added README files to document the expected layout and usage for the embedded MPV runtime artifacts. Entire-Checkpoint: c6e522b4276c
This commit is contained in:
1 parent
10139955e7
commit
9f873e6bed
53 files changed
+6099
-49
No files matched your search
@@ -1,5 +1,6 @@
|
||||
export * from './lib/art-player/art-player.component';
|
||||
export * from './lib/audio-player/audio-player.component';
|
||||
export * from './lib/embedded-mpv-player/embedded-mpv-player.component';
|
||||
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
|
||||
export * from './lib/html-video-player/html-video-player.component';
|
||||
export * from './lib/portal-inline-player/portal-inline-player.component';
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<div
|
||||
#playerRoot
|
||||
class="embedded-mpv-player"
|
||||
[class.embedded-mpv-player--fullscreen]="isFullscreen()"
|
||||
[class.embedded-mpv-player--controls-enabled]="
|
||||
showControls() && isSupported()
|
||||
"
|
||||
[class.embedded-mpv-player--controls-visible]="controlsAreVisible()"
|
||||
[class.embedded-mpv-player--cursor-hidden]="hideCursor()"
|
||||
(pointerenter)="onPlayerInteraction()"
|
||||
(pointermove)="onPlayerInteraction()"
|
||||
(click)="onPlayerInteraction()"
|
||||
(focusin)="onPlayerInteraction()"
|
||||
>
|
||||
<div #viewport class="embedded-mpv-player__viewport">
|
||||
@if (statusLabel()) {
|
||||
<div class="embedded-mpv-player__status">
|
||||
{{ statusLabel() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showControls() && isSupported()) {
|
||||
<div
|
||||
class="embedded-mpv-player__controls"
|
||||
[class.embedded-mpv-player__controls--visible]="controlsAreVisible()"
|
||||
[class.embedded-mpv-player__controls--panel-open]="
|
||||
volumePopoverOpen() || audioMenuOpen()
|
||||
"
|
||||
>
|
||||
@if (volumePopoverOpen()) {
|
||||
<div
|
||||
class="embedded-mpv-player__mode-panel embedded-mpv-player__mode-panel--volume"
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button embedded-mpv-player__back-button"
|
||||
(click)="showDefaultControls()"
|
||||
aria-label="Back to player controls"
|
||||
matTooltip="Back to controls"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-panel"
|
||||
role="dialog"
|
||||
aria-label="Volume"
|
||||
>
|
||||
<mat-icon>{{ volumeIcon() }}</mat-icon>
|
||||
<input
|
||||
class="embedded-mpv-player__slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
[value]="volume()"
|
||||
[attr.aria-label]="volumeLabel()"
|
||||
(input)="onVolumeInput($event)"
|
||||
/>
|
||||
<span class="embedded-mpv-player__volume-value">
|
||||
{{ volumeLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (audioMenuOpen()) {
|
||||
<div
|
||||
class="embedded-mpv-player__mode-panel embedded-mpv-player__mode-panel--audio"
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button embedded-mpv-player__back-button"
|
||||
(click)="showDefaultControls()"
|
||||
aria-label="Back to player controls"
|
||||
matTooltip="Back to controls"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-menu"
|
||||
role="menu"
|
||||
aria-label="Audio tracks"
|
||||
>
|
||||
<div class="embedded-mpv-player__menu-title">
|
||||
Audio tracks
|
||||
</div>
|
||||
<div class="embedded-mpv-player__audio-track-list">
|
||||
@for (
|
||||
track of audioTracks();
|
||||
track track.id;
|
||||
let index = $index
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
[attr.aria-checked]="track.selected"
|
||||
class="embedded-mpv-player__audio-track"
|
||||
[class.embedded-mpv-player__audio-track--selected]="
|
||||
track.selected
|
||||
"
|
||||
(click)="selectAudioTrack(track.id)"
|
||||
>
|
||||
<span>{{ trackLabel(track, index) }}</span>
|
||||
@if (track.selected) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="embedded-mpv-player__transport">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
[disabled]="isLoading()"
|
||||
(click)="togglePaused()"
|
||||
[attr.aria-label]="isPaused() ? 'Play' : 'Pause'"
|
||||
[matTooltip]="isPaused() ? 'Play' : 'Pause'"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isPaused() ? 'play_arrow' : 'pause'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
[disabled]="!canSeek()"
|
||||
(click)="seekBy(-10)"
|
||||
aria-label="Back 10 seconds"
|
||||
matTooltip="Back 10 seconds"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>replay_10</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
[disabled]="!canSeek()"
|
||||
(click)="seekBy(10)"
|
||||
aria-label="Forward 10 seconds"
|
||||
matTooltip="Forward 10 seconds"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>forward_10</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="embedded-mpv-player__timeline">
|
||||
<input
|
||||
class="embedded-mpv-player__slider"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="session()?.durationSeconds ?? 0"
|
||||
[value]="timelineValue()"
|
||||
[disabled]="!canSeek()"
|
||||
(input)="onTimelineInput($event)"
|
||||
/>
|
||||
<div class="embedded-mpv-player__time">
|
||||
<span>{{
|
||||
formatTime(session()?.positionSeconds)
|
||||
}}</span>
|
||||
<span>{{
|
||||
canSeek()
|
||||
? formatTime(session()?.durationSeconds)
|
||||
: 'LIVE'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="embedded-mpv-player__actions">
|
||||
<div class="embedded-mpv-player__popover-anchor">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
(click)="toggleVolumePopover()"
|
||||
[attr.aria-label]="volumeLabel()"
|
||||
[matTooltip]="volumeLabel()"
|
||||
matTooltipPosition="left"
|
||||
>
|
||||
<mat-icon>{{ volumeIcon() }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (hasAudioTracks()) {
|
||||
<div class="embedded-mpv-player__popover-anchor">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
(click)="toggleAudioMenu()"
|
||||
aria-label="Audio tracks"
|
||||
matTooltip="Audio tracks"
|
||||
matTooltipPosition="left"
|
||||
>
|
||||
<mat-icon>graphic_eq</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
[disabled]="!canFullscreen()"
|
||||
(click)="toggleFullscreen()"
|
||||
[attr.aria-label]="fullscreenLabel()"
|
||||
[matTooltip]="fullscreenLabel()"
|
||||
matTooltipPosition="left"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,292 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.embedded-mpv-player {
|
||||
--embedded-mpv-accent: #7dd3fc;
|
||||
--embedded-mpv-glass: rgba(6, 10, 18, 0.78);
|
||||
--embedded-mpv-border: rgba(255, 255, 255, 0.12);
|
||||
--embedded-mpv-controls-height: 96px;
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10, 14, 24, 0.08), rgba(10, 14, 24, 0.24)),
|
||||
#02050a;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embedded-mpv-player--cursor-hidden {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.embedded-mpv-player:fullscreen,
|
||||
.embedded-mpv-player--fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-height: 0;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(96, 125, 139, 0.24), transparent 58%),
|
||||
linear-gradient(180deg, rgba(6, 8, 13, 0.88), rgba(2, 4, 8, 0.98));
|
||||
}
|
||||
|
||||
.embedded-mpv-player--controls-enabled .embedded-mpv-player__viewport {
|
||||
bottom: var(--embedded-mpv-controls-height);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__status {
|
||||
position: absolute;
|
||||
inset: auto 16px 16px 16px;
|
||||
padding: 10px 12px;
|
||||
color: rgba(245, 247, 250, 0.94);
|
||||
background: rgba(7, 10, 16, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(14px);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__controls {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
height: var(--embedded-mpv-controls-height);
|
||||
padding: 12px 18px 14px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(14px);
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 5, 10, 0.28), rgba(5, 9, 15, 0.88) 30%),
|
||||
linear-gradient(180deg, rgba(12, 18, 29, 0.82), rgba(5, 9, 15, 0.94));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 -28px 70px rgba(0, 0, 0, 0.46);
|
||||
backdrop-filter: blur(18px);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__controls--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__controls--panel-open {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 5, 10, 0.42), rgba(5, 9, 15, 0.92) 28%),
|
||||
linear-gradient(180deg, rgba(12, 18, 29, 0.9), rgba(5, 9, 15, 0.98));
|
||||
}
|
||||
|
||||
.embedded-mpv-player__transport {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__time {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: rgba(226, 232, 240, 0.84);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__popover-anchor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__control-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__slider {
|
||||
width: 100%;
|
||||
accent-color: var(--embedded-mpv-accent);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__control-panel {
|
||||
min-width: 0;
|
||||
color: rgba(240, 247, 255, 0.94);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(17, 26, 39, 0.9), rgba(5, 9, 16, 0.94));
|
||||
border: 1px solid var(--embedded-mpv-border);
|
||||
border-radius: 18px;
|
||||
box-shadow:
|
||||
0 18px 50px rgba(0, 0, 0, 0.42),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__mode-panel {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__back-button {
|
||||
color: rgba(241, 245, 249, 0.88);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-panel {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(140px, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-panel mat-icon {
|
||||
color: var(--embedded-mpv-accent);
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-value {
|
||||
color: rgba(226, 232, 240, 0.82);
|
||||
font-size: 0.76rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-menu {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__menu-title {
|
||||
padding: 0 2px;
|
||||
color: rgba(226, 232, 240, 0.7);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track-list {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 154px;
|
||||
max-width: 240px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
color: rgba(241, 245, 249, 0.88);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track:hover,
|
||||
.embedded-mpv-player__audio-track--selected {
|
||||
color: #f8fafc;
|
||||
background: rgba(125, 211, 252, 0.16);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track mat-icon {
|
||||
color: var(--embedded-mpv-accent);
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player :is(button[mat-icon-button]) {
|
||||
color: rgba(241, 245, 249, 0.9);
|
||||
}
|
||||
|
||||
.embedded-mpv-player :is(button[mat-icon-button]:hover) {
|
||||
color: #ffffff;
|
||||
background: rgba(125, 211, 252, 0.14);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.embedded-mpv-player {
|
||||
--embedded-mpv-controls-height: 148px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__controls {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__actions {
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-panel {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-value {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__mode-panel--audio
|
||||
.embedded-mpv-player__audio-menu {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
OnDestroy,
|
||||
Output,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import {
|
||||
EmbeddedMpvAudioTrack,
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-embedded-mpv-player',
|
||||
templateUrl: './embedded-mpv-player.component.html',
|
||||
styleUrl: './embedded-mpv-player.component.scss',
|
||||
imports: [MatButtonModule, MatIconModule, MatTooltipModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'embedded-mpv-player-host',
|
||||
},
|
||||
})
|
||||
export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
readonly playback = input.required<ResolvedPortalPlayback>();
|
||||
readonly showControls = input(true);
|
||||
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
|
||||
readonly viewport = viewChild<ElementRef<HTMLDivElement>>('viewport');
|
||||
readonly playerRoot = viewChild<ElementRef<HTMLDivElement>>('playerRoot');
|
||||
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
||||
readonly session = signal<EmbeddedMpvSession | null>(null);
|
||||
readonly sessionId = signal<string | null>(null);
|
||||
readonly volume = signal(this.readStoredVolume());
|
||||
readonly isFullscreen = signal(false);
|
||||
readonly controlsVisible = signal(true);
|
||||
readonly volumePopoverOpen = signal(false);
|
||||
readonly audioMenuOpen = signal(false);
|
||||
|
||||
readonly isSupported = computed(() => this.support()?.supported ?? false);
|
||||
readonly canFullscreen = computed(
|
||||
() =>
|
||||
typeof document !== 'undefined' &&
|
||||
Boolean(this.playerRoot()?.nativeElement.requestFullscreen) &&
|
||||
Boolean(document.exitFullscreen)
|
||||
);
|
||||
readonly isLoading = computed(
|
||||
() =>
|
||||
!this.support() ||
|
||||
this.session()?.status === 'loading' ||
|
||||
(!this.session() && this.isSupported())
|
||||
);
|
||||
readonly isPaused = computed(
|
||||
() => this.session()?.status === 'paused' || this.session()?.status === 'idle'
|
||||
);
|
||||
readonly isPlaying = computed(() => this.session()?.status === 'playing');
|
||||
readonly canSeek = computed(
|
||||
() => (this.session()?.durationSeconds ?? 0) > 0
|
||||
);
|
||||
readonly statusLabel = computed(() => {
|
||||
const session = this.session();
|
||||
if (session?.status === 'error') {
|
||||
return session.error ?? 'Embedded MPV playback failed.';
|
||||
}
|
||||
|
||||
if (!this.support()) {
|
||||
return 'Checking embedded MPV support...';
|
||||
}
|
||||
|
||||
if (!this.isSupported()) {
|
||||
return (
|
||||
this.support()?.reason ??
|
||||
'Embedded MPV is not available in this environment.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || session.status === 'loading') {
|
||||
return 'Loading stream in MPV...';
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
readonly fullscreenLabel = computed(() =>
|
||||
this.isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'
|
||||
);
|
||||
readonly audioTracks = computed(
|
||||
() => this.session()?.audioTracks ?? []
|
||||
);
|
||||
readonly hasAudioTracks = computed(() => this.audioTracks().length > 1);
|
||||
readonly selectedAudioTrack = computed(
|
||||
() => this.audioTracks().find((track) => track.selected) ?? null
|
||||
);
|
||||
readonly controlsAreVisible = computed(
|
||||
() =>
|
||||
this.showControls() &&
|
||||
this.isSupported() &&
|
||||
(this.controlsVisible() ||
|
||||
this.isLoading() ||
|
||||
this.isPaused() ||
|
||||
this.volumePopoverOpen() ||
|
||||
this.audioMenuOpen() ||
|
||||
Boolean(this.statusLabel()))
|
||||
);
|
||||
readonly hideCursor = computed(
|
||||
() =>
|
||||
this.isFullscreen() &&
|
||||
this.isPlaying() &&
|
||||
!this.controlsAreVisible()
|
||||
);
|
||||
readonly volumeIcon = computed(() => {
|
||||
const value = this.volume();
|
||||
if (value <= 0) {
|
||||
return 'volume_off';
|
||||
}
|
||||
|
||||
return value < 0.5 ? 'volume_down' : 'volume_up';
|
||||
});
|
||||
readonly volumeLabel = computed(
|
||||
() => `Volume ${Math.round(this.volume() * 100)}%`
|
||||
);
|
||||
readonly timelineValue = computed(() =>
|
||||
Math.max(0, this.session()?.positionSeconds ?? 0)
|
||||
);
|
||||
|
||||
private readonly unsubscribeSessionUpdate =
|
||||
window.electron?.onEmbeddedMpvSessionUpdate?.((session) => {
|
||||
if (session.id !== this.sessionId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.session.set(session);
|
||||
this.volume.set(session.volume);
|
||||
this.timeUpdate.emit({
|
||||
currentTime: session.positionSeconds,
|
||||
duration: session.durationSeconds ?? 0,
|
||||
});
|
||||
this.scheduleControlsHide();
|
||||
});
|
||||
private boundsAnimationFrame: number | null = null;
|
||||
private controlsHideTimer: number | null = null;
|
||||
private activeBoundsSync: (() => void) | null = null;
|
||||
private readonly onDocumentPointerDown = (event: PointerEvent) => {
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
const path = event.composedPath();
|
||||
if (!playerRoot || path.includes(playerRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closePopovers();
|
||||
};
|
||||
private readonly onDocumentPointerMove = (event: PointerEvent) => {
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
if (
|
||||
playerRoot &&
|
||||
!event.composedPath().includes(playerRoot) &&
|
||||
this.isPointerInsidePlayer(event)
|
||||
) {
|
||||
this.revealControls();
|
||||
}
|
||||
};
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closePopovers();
|
||||
};
|
||||
private readonly onFullscreenChange = () => {
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
this.isFullscreen.set(
|
||||
Boolean(playerRoot && document.fullscreenElement === playerRoot)
|
||||
);
|
||||
this.revealControls();
|
||||
this.activeBoundsSync?.();
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener(
|
||||
'fullscreenchange',
|
||||
this.onFullscreenChange
|
||||
);
|
||||
document.addEventListener(
|
||||
'pointerdown',
|
||||
this.onDocumentPointerDown
|
||||
);
|
||||
document.addEventListener(
|
||||
'pointermove',
|
||||
this.onDocumentPointerMove,
|
||||
{ passive: true }
|
||||
);
|
||||
document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
}
|
||||
|
||||
if (window.electron?.getEmbeddedMpvSupport) {
|
||||
void this.loadSupport();
|
||||
} else {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: typeof window === 'undefined' ? 'web' : 'unknown',
|
||||
reason: 'Embedded MPV requires the Electron desktop build.',
|
||||
});
|
||||
}
|
||||
|
||||
effect((onCleanup) => {
|
||||
const viewport = this.viewport();
|
||||
const playback = this.playback();
|
||||
const support = this.support();
|
||||
|
||||
if (
|
||||
!viewport ||
|
||||
!playback.streamUrl ||
|
||||
!support?.supported ||
|
||||
!window.electron
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let activeSessionId: string | null = null;
|
||||
const host = viewport.nativeElement;
|
||||
const initialVolume = untracked(() => this.volume());
|
||||
|
||||
const syncBounds = () => {
|
||||
if (!activeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void window.electron
|
||||
.setEmbeddedMpvBounds(
|
||||
activeSessionId,
|
||||
this.measureBounds(host)
|
||||
)
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const scheduleBoundsSync = () => {
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
}
|
||||
|
||||
this.boundsAnimationFrame = requestAnimationFrame(() => {
|
||||
this.boundsAnimationFrame = null;
|
||||
syncBounds();
|
||||
});
|
||||
};
|
||||
|
||||
this.activeBoundsSync = scheduleBoundsSync;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
scheduleBoundsSync();
|
||||
});
|
||||
resizeObserver.observe(host);
|
||||
|
||||
window.addEventListener('resize', scheduleBoundsSync);
|
||||
window.addEventListener('scroll', scheduleBoundsSync, true);
|
||||
|
||||
const createSession = async () => {
|
||||
this.session.set(
|
||||
this.createLoadingSession(playback, initialVolume)
|
||||
);
|
||||
await this.waitForStartupPaint();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prepared = await window.electron!.prepareEmbeddedMpv?.();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prepared && !prepared.supported) {
|
||||
throw new Error(
|
||||
prepared.reason ??
|
||||
'Embedded MPV is not available in this environment.'
|
||||
);
|
||||
}
|
||||
|
||||
const createdSession =
|
||||
await window.electron!.createEmbeddedMpvSession(
|
||||
this.measureBounds(host),
|
||||
playback.title,
|
||||
initialVolume
|
||||
);
|
||||
|
||||
if (disposed) {
|
||||
await window.electron!.disposeEmbeddedMpvSession(
|
||||
createdSession.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
activeSessionId = createdSession.id;
|
||||
this.sessionId.set(createdSession.id);
|
||||
this.session.set(createdSession);
|
||||
|
||||
await window.electron!.loadEmbeddedMpvPlayback(
|
||||
createdSession.id,
|
||||
playback
|
||||
);
|
||||
scheduleBoundsSync();
|
||||
};
|
||||
|
||||
void createSession().catch((error) => {
|
||||
const startedAt = new Date().toISOString();
|
||||
this.sessionId.set(null);
|
||||
this.session.set({
|
||||
id: 'embedded-mpv-error',
|
||||
title: playback.title,
|
||||
streamUrl: playback.streamUrl,
|
||||
status: 'error',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume: initialVolume,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true;
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', scheduleBoundsSync);
|
||||
window.removeEventListener('scroll', scheduleBoundsSync, true);
|
||||
|
||||
if (this.activeBoundsSync === scheduleBoundsSync) {
|
||||
this.activeBoundsSync = null;
|
||||
}
|
||||
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
this.boundsAnimationFrame = null;
|
||||
}
|
||||
|
||||
const sessionId = activeSessionId;
|
||||
activeSessionId = null;
|
||||
this.sessionId.set(null);
|
||||
this.session.set(null);
|
||||
|
||||
if (sessionId) {
|
||||
void window.electron?.disposeEmbeddedMpvSession(sessionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeSessionUpdate?.();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener(
|
||||
'fullscreenchange',
|
||||
this.onFullscreenChange
|
||||
);
|
||||
document.removeEventListener(
|
||||
'pointerdown',
|
||||
this.onDocumentPointerDown
|
||||
);
|
||||
document.removeEventListener(
|
||||
'pointermove',
|
||||
this.onDocumentPointerMove
|
||||
);
|
||||
document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
}
|
||||
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
this.boundsAnimationFrame = null;
|
||||
}
|
||||
|
||||
this.clearControlsHideTimer();
|
||||
}
|
||||
|
||||
onPlayerInteraction(): void {
|
||||
this.revealControls();
|
||||
}
|
||||
|
||||
async togglePaused(): Promise<void> {
|
||||
this.revealControls();
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSession = await window.electron.setEmbeddedMpvPaused(
|
||||
session.id,
|
||||
session.status !== 'paused'
|
||||
);
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleFullscreen(): Promise<void> {
|
||||
this.revealControls();
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
if (!playerRoot || !this.canFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (document.fullscreenElement === playerRoot) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await playerRoot.requestFullscreen();
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
this.activeBoundsSync?.();
|
||||
}
|
||||
}
|
||||
|
||||
async seekBy(deltaSeconds: number): Promise<void> {
|
||||
this.revealControls();
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPosition = Math.max(0, session.positionSeconds + deltaSeconds);
|
||||
const updatedSession = await window.electron.seekEmbeddedMpv(
|
||||
session.id,
|
||||
nextPosition
|
||||
);
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
}
|
||||
|
||||
async onTimelineInput(event: Event): Promise<void> {
|
||||
this.revealControls();
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = Number((event.target as HTMLInputElement).value);
|
||||
const updatedSession = await window.electron.seekEmbeddedMpv(
|
||||
session.id,
|
||||
target
|
||||
);
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
}
|
||||
|
||||
toggleVolumePopover(): void {
|
||||
this.volumePopoverOpen.update((open) => !open);
|
||||
if (this.volumePopoverOpen()) {
|
||||
this.audioMenuOpen.set(false);
|
||||
}
|
||||
this.revealControls();
|
||||
this.activeBoundsSync?.();
|
||||
}
|
||||
|
||||
toggleAudioMenu(): void {
|
||||
this.audioMenuOpen.update((open) => !open);
|
||||
if (this.audioMenuOpen()) {
|
||||
this.volumePopoverOpen.set(false);
|
||||
}
|
||||
this.revealControls();
|
||||
this.activeBoundsSync?.();
|
||||
}
|
||||
|
||||
showDefaultControls(): void {
|
||||
this.volumePopoverOpen.set(false);
|
||||
this.audioMenuOpen.set(false);
|
||||
this.revealControls();
|
||||
this.activeBoundsSync?.();
|
||||
}
|
||||
|
||||
onVolumeInput(event: Event): void {
|
||||
const nextVolume = Number((event.target as HTMLInputElement).value);
|
||||
this.volume.set(nextVolume);
|
||||
localStorage.setItem('volume', String(nextVolume));
|
||||
this.revealControls(false);
|
||||
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvVolume) {
|
||||
return;
|
||||
}
|
||||
|
||||
void window.electron
|
||||
.setEmbeddedMpvVolume(session.id, nextVolume)
|
||||
.then((updatedSession) => {
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async selectAudioTrack(trackId: number): Promise<void> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvAudioTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revealControls(false);
|
||||
const updatedSession = await window.electron.setEmbeddedMpvAudioTrack(
|
||||
session.id,
|
||||
trackId
|
||||
);
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
this.audioMenuOpen.set(false);
|
||||
this.scheduleControlsHide();
|
||||
}
|
||||
|
||||
formatTime(value: number | null | undefined): string {
|
||||
const safeValue = Math.max(0, Math.floor(value ?? 0));
|
||||
const hours = Math.floor(safeValue / 3600);
|
||||
const minutes = Math.floor((safeValue % 3600) / 60);
|
||||
const seconds = safeValue % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
trackLabel(track: EmbeddedMpvAudioTrack, index: number): string {
|
||||
const label = track.title || track.language || `Audio ${index + 1}`;
|
||||
return track.defaultTrack ? `${label} · Default` : label;
|
||||
}
|
||||
|
||||
private closePopovers(): void {
|
||||
if (!this.volumePopoverOpen() && !this.audioMenuOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.volumePopoverOpen.set(false);
|
||||
this.audioMenuOpen.set(false);
|
||||
this.activeBoundsSync?.();
|
||||
this.scheduleControlsHide();
|
||||
}
|
||||
|
||||
private revealControls(scheduleHide = true): void {
|
||||
this.controlsVisible.set(true);
|
||||
if (scheduleHide) {
|
||||
this.clearControlsHideTimer();
|
||||
this.scheduleControlsHide();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleControlsHide(): void {
|
||||
if (
|
||||
!this.isPlaying() ||
|
||||
this.volumePopoverOpen() ||
|
||||
this.audioMenuOpen() ||
|
||||
Boolean(this.statusLabel())
|
||||
) {
|
||||
this.clearControlsHideTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.controlsVisible() || this.controlsHideTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlsHideTimer = window.setTimeout(() => {
|
||||
if (
|
||||
this.isPlaying() &&
|
||||
!this.volumePopoverOpen() &&
|
||||
!this.audioMenuOpen() &&
|
||||
!this.statusLabel()
|
||||
) {
|
||||
this.controlsVisible.set(false);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
private clearControlsHideTimer(): void {
|
||||
if (this.controlsHideTimer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(this.controlsHideTimer);
|
||||
this.controlsHideTimer = null;
|
||||
}
|
||||
|
||||
private isPointerInsidePlayer(event: PointerEvent): boolean {
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
if (!playerRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = playerRoot.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
private async loadSupport(): Promise<void> {
|
||||
try {
|
||||
this.support.set(await window.electron!.getEmbeddedMpvSupport());
|
||||
} catch (error) {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron?.platform ?? 'unknown',
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private createLoadingSession(
|
||||
playback: ResolvedPortalPlayback,
|
||||
volume: number
|
||||
): EmbeddedMpvSession {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: 'embedded-mpv-starting',
|
||||
title: playback.title,
|
||||
streamUrl: playback.streamUrl,
|
||||
status: 'loading',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
private waitForStartupPaint(): Promise<void> {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private measureBounds(host: HTMLDivElement): EmbeddedMpvBounds {
|
||||
const rect = host.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.max(1, Math.round(rect.width)),
|
||||
height: Math.max(1, Math.round(rect.height)),
|
||||
};
|
||||
}
|
||||
|
||||
private readStoredVolume(): number {
|
||||
const rawValue = Number(localStorage.getItem('volume') ?? '1');
|
||||
if (Number.isNaN(rawValue)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(1, rawValue));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@
|
||||
<div class="player-shell__viewport">
|
||||
<app-web-player-view
|
||||
[streamUrl]="streamUrl()"
|
||||
[title]="title()"
|
||||
[playback]="playback()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="onTimeUpdate($event)"
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
/>
|
||||
} @else if (player === 'embedded-mpv') {
|
||||
<app-embedded-mpv-player
|
||||
[playback]="resolvedPlayback()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
}
|
||||
|
||||
app-art-player,
|
||||
app-embedded-mpv-player,
|
||||
app-vjs-player,
|
||||
app-html-video-player {
|
||||
display: block;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Output,
|
||||
Signal,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
@@ -11,8 +12,14 @@ import {
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import { Settings, STORE_KEY, VideoPlayer } from 'shared-interfaces';
|
||||
import {
|
||||
ResolvedPortalPlayback,
|
||||
Settings,
|
||||
STORE_KEY,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
||||
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
|
||||
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
|
||||
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
|
||||
@@ -20,13 +27,20 @@ import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
selector: 'app-web-player-view',
|
||||
templateUrl: './web-player-view.component.html',
|
||||
styleUrls: ['./web-player-view.component.scss'],
|
||||
imports: [ArtPlayerComponent, HtmlVideoPlayerComponent, VjsPlayerComponent],
|
||||
imports: [
|
||||
ArtPlayerComponent,
|
||||
EmbeddedMpvPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class WebPlayerViewComponent {
|
||||
storage = inject(StorageMap);
|
||||
|
||||
streamUrl = input.required<string>();
|
||||
title = input('');
|
||||
playback = input<ResolvedPortalPlayback | null>(null);
|
||||
startTime = input<number>(0);
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
@@ -40,13 +54,26 @@ export class WebPlayerViewComponent {
|
||||
channel!: { url: string };
|
||||
player!: VideoPlayer;
|
||||
vjsOptions!: { sources: { src: string; type: string }[] };
|
||||
readonly resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
|
||||
const playback = this.playback();
|
||||
if (playback) {
|
||||
return playback;
|
||||
}
|
||||
|
||||
return {
|
||||
streamUrl: this.streamUrl(),
|
||||
title: this.title() || this.streamUrl(),
|
||||
startTime: this.startTime(),
|
||||
};
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.player = this.settings()?.player ?? VideoPlayer.VideoJs;
|
||||
|
||||
this.setChannel(this.streamUrl());
|
||||
this.setVjsOptions(this.streamUrl());
|
||||
const playback = this.resolvedPlayback();
|
||||
this.setChannel(playback.streamUrl);
|
||||
this.setVjsOptions(playback.streamUrl);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user