mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
Phase 4 of the "Hybrid" player controls redesign. Series hosts hand the episode after the playing one to the shared controls (`upNext` on PortalInlinePlayerComponent → WebPlayerViewComponent → the four engine hosts → app-player-controls). ControlsUpNext shows the glass card once at most eight minutes remain on a seekable episode with a next-episode capable engine; it yields to the settings panel and to hidden controls. A click emits nextEpisodeRequested, so the switch keeps fullscreen like the transport button. Compact docks get a smaller card. Also dedupes the web-player-view spec's local engine stubs in favour of the shared stub file its sibling specs already use. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
import {
|
|
Component,
|
|
ElementRef,
|
|
Injector,
|
|
OnDestroy,
|
|
ViewEncapsulation,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
input,
|
|
linkedSignal,
|
|
output,
|
|
signal,
|
|
untracked,
|
|
viewChild,
|
|
ChangeDetectionStrategy,
|
|
} from '@angular/core';
|
|
import {
|
|
type PlaybackDiagnostic,
|
|
type PlaybackDiagnosticCode,
|
|
type PlaybackFallbackRequest,
|
|
type PlaybackRecommendationTarget,
|
|
} from '@iptvnator/playback/util';
|
|
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
|
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
|
import {
|
|
VideoPlayer,
|
|
type Channel,
|
|
type RecordingStartMetadata,
|
|
type RecordingStoppedEvent,
|
|
type ResolvedPortalPlayback,
|
|
type VodSourceDescriptor,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
|
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
|
|
import { FullscreenChannelPanelComponent } from '../fullscreen-channel-panel/fullscreen-channel-panel.component';
|
|
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
|
|
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
|
|
import {
|
|
type PlayerMediaTitle,
|
|
PlayerUpNextItem,
|
|
WEB_PLAYER_SHARED_CONTROLS,
|
|
} from '../player-controls';
|
|
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
|
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
|
import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types';
|
|
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
|
import { ExternalPlaybackRecoveryCoordinator } from './external-playback-recovery-coordinator';
|
|
import {
|
|
type PlaybackBinding,
|
|
PlaybackRecoverySession,
|
|
} from './playback-recovery-session';
|
|
import { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff';
|
|
import {
|
|
ownsPlaybackApplication,
|
|
type PlaybackApplicationOwnership,
|
|
} from './web-player-application-ownership';
|
|
import { createWebPlayerApplicationState } from './web-player-application-state';
|
|
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
|
|
import { createChannelPanelAvailability } from './web-player-channel-panel-state';
|
|
import { WebPlayerLiveAutoFormat } from './web-player-live-auto-format';
|
|
import { WebPlayerRecoveryController } from './web-player-recovery-controller';
|
|
import {
|
|
isPlaybackExternallyTransferable,
|
|
resolveRenderableWebPlayer,
|
|
toInlinePlaybackPlayer,
|
|
toVideoPlayer,
|
|
} from './web-player-recovery-policy';
|
|
import { resolveWebPlayerSharedControls } from './web-player-shared-controls';
|
|
|
|
@Component({
|
|
selector: 'app-web-player-view',
|
|
templateUrl: './web-player-view.component.html',
|
|
styleUrls: ['./web-player-view.component.scss'],
|
|
host: { class: 'web-player-view' },
|
|
imports: [
|
|
ArtPlayerComponent,
|
|
EmbeddedMpvPlayerComponent,
|
|
FullscreenChannelPanelComponent,
|
|
HtmlVideoPlayerComponent,
|
|
PlaybackDiagnosticPanelComponent,
|
|
VjsPlayerComponent,
|
|
],
|
|
providers: [
|
|
{
|
|
provide: WEB_PLAYER_SHARED_CONTROLS,
|
|
useFactory: resolveWebPlayerSharedControls,
|
|
},
|
|
],
|
|
changeDetection: ChangeDetectionStrategy.Eager,
|
|
encapsulation: ViewEncapsulation.None,
|
|
})
|
|
export class WebPlayerViewComponent implements OnDestroy {
|
|
/**
|
|
* DOM fullscreen owner handed to every rendered player. Applications are
|
|
* remounted per token (`@for ... track application.token` below), and
|
|
* the Fullscreen API exits the moment its element leaves the document,
|
|
* so a fullscreen owned by the player shell would end with every
|
|
* episode, channel, or alternative-source switch. This host element
|
|
* spans all applications of one mount: fullscreen entered on episode 1
|
|
* is still in place when episode 2's engine mounts, and the fresh
|
|
* controls pick it up through `ControlsFullscreen.sync()`. It is also
|
|
* the stage the fullscreen channel panel tracks, so the panel sits inside
|
|
* the fullscreen element and survives the same remounts.
|
|
*/
|
|
readonly fullscreenSurface: HTMLElement = inject(ElementRef<HTMLElement>)
|
|
.nativeElement;
|
|
private readonly embeddedMpvPlayer =
|
|
viewChild<EmbeddedMpvPlayerComponent>('embeddedMpvPlayer');
|
|
readonly channelPanelAvailable = createChannelPanelAvailability(
|
|
() => this.renderedApplications().some((app) => app.embeddedMpv),
|
|
() => this.embeddedMpvPlayer()?.support() ?? null
|
|
);
|
|
private readonly runtime = inject(RuntimeCapabilitiesService);
|
|
private readonly settingsStore = inject(SettingsStore);
|
|
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, {
|
|
optional: true,
|
|
});
|
|
private readonly recoverySession = new PlaybackRecoverySession();
|
|
private readonly externalRecovery = new ExternalPlaybackRecoveryCoordinator(
|
|
this.externalPlayback
|
|
);
|
|
private readonly applicationHandoff =
|
|
new WebPlayerApplicationHandoffCoordinator(
|
|
inject(ElectronStreamHeadersService),
|
|
this.recoverySession
|
|
);
|
|
|
|
readonly streamUrl = input.required<string>();
|
|
readonly playbackSessionKey = input.required<string>();
|
|
readonly title = input('');
|
|
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
|
readonly startTime = input(0);
|
|
readonly volume = input(1);
|
|
readonly playerOverride = input<VideoPlayer | null>(null);
|
|
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
|
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
|
|
/** Next episode for the shared controls' "Up next" card; series hosts only. */
|
|
readonly upNext = input<PlayerUpNextItem | null>(null);
|
|
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
|
|
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
|
|
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
|
|
|
|
readonly timeUpdate = output<{
|
|
currentTime: number;
|
|
duration: number;
|
|
}>();
|
|
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
|
readonly alternativeSourceRequested = output<string>();
|
|
readonly sourceCheckRequested = output<string>();
|
|
readonly playbackFailed = output<PlaybackDiagnosticCode>();
|
|
readonly playbackEnded = output<void>();
|
|
readonly previousEpisodeRequested = output<void>();
|
|
readonly nextEpisodeRequested = output<void>();
|
|
/** Re-emitted embedded-MPV clean recording stop (stop enrichment). */
|
|
readonly recordingStopped = output<RecordingStoppedEvent>();
|
|
|
|
readonly showCaptions = computed(
|
|
() => this.settingsStore.showCaptions?.() ?? false
|
|
);
|
|
readonly reloadToken = signal(0);
|
|
readonly externalRecoveryState = this.externalRecovery.states;
|
|
readonly externalRecoveryPending = this.externalRecovery.pending;
|
|
readonly recoveryPending = computed(
|
|
() =>
|
|
this.recoverySession.switchPending() ||
|
|
this.externalRecoveryPending()
|
|
);
|
|
readonly activeBinding = this.recoverySession.activeBinding;
|
|
|
|
/**
|
|
* Source handed to the rendered engine. Signals, not plain fields: in
|
|
* Electron the handoff lands in the stream-header IPC promise, after the
|
|
* change-detection pass that mounted the application, and this view sits
|
|
* under OnPush hosts (`PortalInlinePlayerComponent`) — a plain field set
|
|
* there was only rendered when something else happened to dirty the
|
|
* subtree, which used to be the fullscreen exit on every episode switch.
|
|
*/
|
|
readonly channel = signal<Channel | undefined>(undefined);
|
|
readonly vjsOptions = signal<VideoPlayerOptions | undefined>(undefined);
|
|
|
|
// Resolved from the live SettingsStore signal, not a mount-time storage
|
|
// snapshot: a saved player change (settings page, command palette) must
|
|
// reach an already-mounted player without a remount. A saved managed
|
|
// MPV/VLC retains the mounted engine (resolveRenderableWebPlayer),
|
|
// because this view can neither render nor launch external players.
|
|
private readonly renderablePlayer = linkedSignal({
|
|
source: () =>
|
|
this.playerOverride() ??
|
|
this.settingsStore.player?.() ??
|
|
VideoPlayer.VideoJs,
|
|
computation: resolveRenderableWebPlayer,
|
|
});
|
|
readonly selectedPlayer = computed<VideoPlayer>(() => {
|
|
const temporary = this.recoverySession.temporaryPlayerOverride();
|
|
return temporary ? toVideoPlayer(temporary) : this.renderablePlayer();
|
|
});
|
|
private readonly liveAutoFormat = new WebPlayerLiveAutoFormat({
|
|
playback: this.playback,
|
|
sessionKey: this.playbackSessionKey,
|
|
player: this.selectedPlayer,
|
|
intent: this.reloadToken,
|
|
autoEnabled: () =>
|
|
(this.settingsStore.streamFormat?.() ?? 'auto') === 'auto',
|
|
injector: inject(Injector),
|
|
});
|
|
private readonly applicationState = createWebPlayerApplicationState({
|
|
playback: this.liveAutoFormat.playback,
|
|
streamUrl: this.streamUrl,
|
|
title: this.title,
|
|
startTime: this.startTime,
|
|
selectedPlayer: this.selectedPlayer,
|
|
reloadToken: this.reloadToken,
|
|
});
|
|
readonly resolvedPlayback = this.applicationState.playback;
|
|
readonly resolvedIsLive = this.applicationState.isLive;
|
|
readonly playbackSourceRevisionToken = this.applicationState.sourceRevision;
|
|
readonly playbackApplicationToken = this.applicationState.token;
|
|
readonly playbackExternallyTransferable = computed(() =>
|
|
isPlaybackExternallyTransferable(this.resolvedPlayback())
|
|
);
|
|
private readonly recovery = new WebPlayerRecoveryController({
|
|
recoverySession: this.recoverySession,
|
|
externalRecovery: this.externalRecovery,
|
|
applicationHandoff: this.applicationHandoff,
|
|
playbackSessionKey: this.playbackSessionKey,
|
|
playback: this.liveAutoFormat.playback,
|
|
streamUrl: this.streamUrl,
|
|
startTime: this.startTime,
|
|
selectedPlayer: this.selectedPlayer,
|
|
reloadToken: this.reloadToken,
|
|
playbackApplicationToken: this.playbackApplicationToken,
|
|
resolvedPlayback: this.resolvedPlayback,
|
|
resolvedIsLive: this.resolvedIsLive,
|
|
playbackExternallyTransferable: this.playbackExternallyTransferable,
|
|
alternativeSourceCount: () => this.alternativeSources().length,
|
|
managedExternalPlayersAvailable: () =>
|
|
this.runtime.supportsManagedExternalPlayers,
|
|
tryAutoLiveFormat: (issue) => this.liveAutoFormat.tryFallback(issue),
|
|
emitPlaybackFailed: (code) => this.playbackFailed.emit(code),
|
|
emitExternalFallbackRequested: (request) =>
|
|
this.externalFallbackRequested.emit(request),
|
|
});
|
|
readonly playbackDiagnostic = this.recovery.playbackDiagnostic;
|
|
readonly visiblePlaybackDiagnostic =
|
|
this.recovery.visiblePlaybackDiagnostic;
|
|
readonly recommendations = this.recovery.recommendations;
|
|
readonly effectiveStartTime = computed(() =>
|
|
this.recoverySession.resumeStartTime(
|
|
this.startTime(),
|
|
this.resolvedIsLive()
|
|
)
|
|
);
|
|
readonly playbackInteractionEnabled = computed(
|
|
() => this.visiblePlaybackDiagnostic() === null
|
|
);
|
|
readonly resolvedMediaTitle = computed(() =>
|
|
resolveWebPlayerMediaTitle(this.mediaTitle(), this.resolvedPlayback())
|
|
);
|
|
readonly recordingFolder = computed(
|
|
() => this.settingsStore.recordingFolder?.() ?? ''
|
|
);
|
|
get supportsManagedExternalPlayers(): boolean {
|
|
return this.runtime.supportsManagedExternalPlayers;
|
|
}
|
|
readonly renderedApplications = computed<
|
|
readonly PlaybackApplicationOwnership[]
|
|
>(() => {
|
|
if (this.liveAutoFormat.pending()) return [];
|
|
const binding = this.activeBinding();
|
|
const embeddedMpv =
|
|
this.selectedPlayer() === VideoPlayer.EmbeddedMpv && !binding;
|
|
if (!binding && !embeddedMpv) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
Object.freeze({
|
|
binding,
|
|
embeddedMpv,
|
|
isLive: this.resolvedIsLive(),
|
|
sourceRevision: this.playbackSourceRevisionToken(),
|
|
token: this.playbackApplicationToken(),
|
|
}),
|
|
];
|
|
});
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
const session = this.externalPlayback?.activeSession() ?? null;
|
|
untracked(() => this.externalRecovery.observe(session));
|
|
});
|
|
effect(() => {
|
|
// Session sync may clear a temporary player override. Run it before
|
|
// tracking intent so that reset is folded into this application.
|
|
this.recovery.syncSession();
|
|
if (this.liveAutoFormat.pending()) return;
|
|
void this.recovery.diagnosticIntentToken();
|
|
const sourceRevision = this.playbackSourceRevisionToken();
|
|
untracked(() =>
|
|
this.recoverySession.syncSourceRevision(sourceRevision)
|
|
);
|
|
const token = this.playbackApplicationToken();
|
|
const playback = untracked(this.resolvedPlayback);
|
|
const isLive = untracked(this.resolvedIsLive);
|
|
const selectedPlayer = untracked(this.selectedPlayer);
|
|
const reloadToken = untracked(this.reloadToken);
|
|
const target = toInlinePlaybackPlayer(selectedPlayer);
|
|
this.channel.set(undefined);
|
|
this.vjsOptions.set(undefined);
|
|
this.recovery.clearDiagnostic();
|
|
if (target === null) {
|
|
this.applicationHandoff.release();
|
|
this.recoverySession.clearPlaybackBinding();
|
|
return;
|
|
}
|
|
const binding = untracked(() =>
|
|
this.recoverySession.beginPlayback(target)
|
|
);
|
|
this.applicationHandoff.apply(
|
|
playback,
|
|
isLive,
|
|
reloadToken,
|
|
binding,
|
|
token,
|
|
() => this.playbackApplicationToken(),
|
|
(handoff) => {
|
|
this.channel.set(handoff.channel);
|
|
this.vjsOptions.set(handoff.vjsOptions);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.liveAutoFormat.destroy();
|
|
this.externalRecovery.destroy();
|
|
this.applicationHandoff.destroy();
|
|
}
|
|
|
|
handlePlaybackIssue(
|
|
issue: PlaybackDiagnostic | null,
|
|
binding: PlaybackBinding
|
|
): void {
|
|
this.recovery.handlePlaybackIssue(issue, binding);
|
|
}
|
|
|
|
handlePlaybackStarted(binding: PlaybackBinding): void {
|
|
this.recovery.syncSession();
|
|
if (
|
|
this.applicationHandoff.owns(
|
|
binding,
|
|
this.playbackApplicationToken()
|
|
) &&
|
|
!this.liveAutoFormat.pending()
|
|
)
|
|
this.liveAutoFormat.started();
|
|
}
|
|
|
|
handleTimeUpdate(
|
|
event: { currentTime: number; duration: number },
|
|
ownership: PlaybackApplicationOwnership
|
|
): void {
|
|
if (
|
|
!ownsPlaybackApplication({
|
|
ownership,
|
|
currentToken: this.playbackApplicationToken(),
|
|
currentSourceRevision: this.playbackSourceRevisionToken(),
|
|
bindingOwned: ownership.binding
|
|
? this.applicationHandoff.owns(
|
|
ownership.binding,
|
|
ownership.token
|
|
)
|
|
: false,
|
|
embeddedMpvSelected:
|
|
this.selectedPlayer() === VideoPlayer.EmbeddedMpv,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.recoverySession.recordTimeUpdate(event, ownership.isLive);
|
|
this.timeUpdate.emit(event);
|
|
}
|
|
|
|
requestRecommendedPlayer(target: PlaybackRecommendationTarget): void {
|
|
this.recovery.requestRecommendedPlayer(target);
|
|
}
|
|
|
|
retryPlayback(): void {
|
|
this.recovery.retryPlayback();
|
|
}
|
|
}
|