Files
iptvnator/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
T
3cc5af1492 perf(playback): make the web players and M3U player OnPush (#1822)
* perf(playback): make the web players and M3U player OnPush

Plan item C6 step 3 for playback: the eight Eager components in
libs/ui/playback (video.js, ArtPlayer, HTML5/hls/mpegts, audio player,
web player view, VOD details, sidebar, external-player dialog) and the M3U
video player and VOD detail switch to OnPush. The player libraries' events
already reach the UI through the signal-backed controls adapter or
outputs, and the players' DOM belongs to the libraries.

The M3U video player rendered three plain fields written outside template
events: the channel-number overlay, cleared by a 2 s debounce timer, and
the player choice, written from an IndexedDB read and a settings effect.
They are signals now, and a test checks that the overlay leaves the DOM
when the timer fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(playback): let the overlay's signal write schedule its own render

Review follow-up (Greptile): the test forced a render with
fixture.detectChanges() after the debounce timer, so it would pass even
if the signal write stopped scheduling an OnPush render. It now runs the
fixture with autoDetectChanges and only advances the fake timers; with
plain fields under OnPush the overlay never renders and the test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:12:52 +02:00

420 lines
17 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 { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
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 { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
import {
type PlayerMediaTitle,
PlayerUpNextItem,
type PlayerTimelineSegment,
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.OnPush,
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 historyGate = inject(PlaybackHistoryGate);
private readonly externalRecovery = new ExternalPlaybackRecoveryCoordinator(
this.externalPlayback,
// "Open in MPV/VLC" after an inline failure is still this session's
// view; the app-wide session confirmation carries only the URL.
(session) =>
this.historyGate.confirm({
sessionKey: this.playbackSessionKey(),
streamUrls: [session.streamUrl],
})
);
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);
/** Catch-up programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | 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;
/** Commits deferred "recently viewed" writes once this stream plays. */
private readonly historyConfirmation = new PlaybackHistoryConfirmation({
gate: this.historyGate,
target: () => ({
sessionKey: this.playbackSessionKey(),
streamUrls: [this.streamUrl(), this.playback()?.streamUrl],
}),
sourceRevision: () => this.playbackSourceRevisionToken(),
});
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: PlayerTimeUpdate,
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.historyConfirmation.record(event.currentTime, event.playing);
this.timeUpdate.emit(event);
}
requestRecommendedPlayer(target: PlaybackRecommendationTarget): void {
this.recovery.requestRecommendedPlayer(target);
}
retryPlayback(): void {
this.recovery.retryPlayback();
}
}