From 20857e34c2c45a92af29ce4dfd9f1f049682e3af Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:40:22 +0200 Subject: [PATCH] refactor(playback): extract web player diagnostic/recovery controller (#1444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(playback): extract web player diagnostic/recovery controller WebPlayerViewComponent sat at ~391 counted lines against the 400 max-lines cap. Move the diagnostic/recovery surface — the diagnostic ownership/intent tokens, visible-diagnostic computed, playback-issue handling, recommended-player switching, retry, and session sync — into a dedicated WebPlayerRecoveryController in the same directory. The component stays the template-facing facade with an unchanged public API, so all four spec files pass without modification. Co-Authored-By: Claude Fable 5 * refactor(playback): move recommendations into the recovery controller The controller deps closed over the component's recommendations computed, which itself read the controller's visiblePlaybackDiagnostic — a reference cycle tsc rejects under noImplicitAny (TS7022/TS7023) even though Jest and ESLint accept it. Owning the recommendations computed in WebPlayerRecoveryController removes the cycle at its root and shrinks the facade further; the component re-exposes the signal unchanged for the template and specs. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../web-player-recovery-controller.ts | 189 ++++++++++++++++++ .../web-player-view.component.ts | 167 ++++------------ 2 files changed, 225 insertions(+), 131 deletions(-) create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-recovery-controller.ts diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-controller.ts b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-controller.ts new file mode 100644 index 000000000..02245b692 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-controller.ts @@ -0,0 +1,189 @@ +import { + computed, + signal, + untracked, + type Signal, + type WritableSignal, +} from '@angular/core'; +import type { + PlaybackDiagnostic, + PlaybackDiagnosticCode, + PlaybackFallbackRequest, + PlaybackRecommendationTarget, +} from '@iptvnator/playback/util'; +import { + VideoPlayer, + type ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import type { ExternalPlaybackRecoveryCoordinator } from './external-playback-recovery-coordinator'; +import type { + PlaybackBinding, + PlaybackRecoverySession, +} from './playback-recovery-session'; +import type { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff'; +import type { WebPlayerApplicationToken } from './web-player-application-state'; +import { createWebPlayerRecommendations } from './web-player-recovery-policy'; + +export interface WebPlayerRecoveryControllerDeps { + readonly recoverySession: PlaybackRecoverySession; + readonly externalRecovery: ExternalPlaybackRecoveryCoordinator; + readonly applicationHandoff: WebPlayerApplicationHandoffCoordinator; + readonly playbackSessionKey: Signal; + readonly playback: Signal; + readonly streamUrl: Signal; + readonly startTime: Signal; + readonly selectedPlayer: Signal; + readonly reloadToken: WritableSignal; + readonly playbackApplicationToken: Signal; + readonly resolvedPlayback: Signal; + readonly resolvedIsLive: Signal; + readonly playbackExternallyTransferable: Signal; + readonly alternativeSourceCount: () => number; + readonly managedExternalPlayersAvailable: () => boolean; + readonly emitPlaybackFailed: (code: PlaybackDiagnosticCode) => void; + readonly emitExternalFallbackRequested: ( + request: PlaybackFallbackRequest + ) => void; +} + +/** + * Owns the web player view's diagnostic/recovery surface: diagnostic + * ownership, recommended-player switching, retry, and session sync. The + * component remains the template-facing facade and delegates here. + */ +export class WebPlayerRecoveryController { + readonly playbackDiagnostic = signal(null); + // Diagnostic ownership follows raw intent so an old action disappears; + // the application effect then clears its backing state before handoff. + // Keep this token opaque: it must never retain playback payload fields. + readonly diagnosticIntentToken = computed(() => { + if (this.deps.playback() === null) { + void this.deps.streamUrl(); + void this.deps.startTime(); + } + void this.deps.selectedPlayer(); + void this.deps.reloadToken(); + return Symbol(); + }); + private readonly diagnosticOwnerToken = + signal(null); + readonly visiblePlaybackDiagnostic = computed(() => + this.deps.selectedPlayer() === VideoPlayer.EmbeddedMpv || + this.diagnosticOwnerToken() !== this.diagnosticIntentToken() + ? null + : this.playbackDiagnostic() + ); + readonly recommendations = computed(() => { + const binding = this.deps.recoverySession.activeBinding(); + const token = this.deps.playbackApplicationToken(); + return createWebPlayerRecommendations({ + diagnostic: this.visiblePlaybackDiagnostic(), + binding: + binding && this.deps.applicationHandoff.owns(binding, token) + ? binding + : null, + attemptedTargets: this.deps.recoverySession.attemptedTargets(), + externalStates: this.deps.externalRecovery.states(), + managedExternalPlayersAvailable: + this.deps.managedExternalPlayersAvailable(), + playbackExternallyTransferable: + this.deps.playbackExternallyTransferable(), + isLive: this.deps.resolvedIsLive(), + alternativeSourceCount: this.deps.alternativeSourceCount(), + }); + }); + + constructor(private readonly deps: WebPlayerRecoveryControllerDeps) {} + + handlePlaybackIssue( + issue: PlaybackDiagnostic | null, + binding: PlaybackBinding + ): void { + const { recoverySession, applicationHandoff } = this.deps; + this.syncSession(); + if (!recoverySession.accepts(binding)) { + return; + } + if ( + !applicationHandoff.owns( + binding, + this.deps.playbackApplicationToken() + ) + ) { + applicationHandoff.invalidate(); + recoverySession.clearPlaybackBinding(); + this.clearDiagnostic(); + return; + } + if (!issue) { + recoverySession.settle(binding); + this.clearDiagnostic(); + return; + } + if (!recoverySession.recordFailure(binding)) { + return; + } + this.diagnosticOwnerToken.set(this.diagnosticIntentToken()); + this.playbackDiagnostic.set(issue); + this.deps.emitPlaybackFailed(issue.code); + } + + requestRecommendedPlayer(target: PlaybackRecommendationTarget): void { + const { recoverySession, externalRecovery } = this.deps; + const diagnostic = this.visiblePlaybackDiagnostic(); + if (!diagnostic) { + return; + } + const available = this.recommendations().some( + (item) => item.action === 'player' && item.target === target + ); + if (!available) { + if (target !== 'mpv' && target !== 'vlc') { + recoverySession.recordInlineAttempt(target); + } + return; + } + if (target === 'mpv' || target === 'vlc') { + externalRecovery.request( + target, + () => recoverySession.recordExternalAttempt(target), + (trackLaunch) => { + if (this.visiblePlaybackDiagnostic() !== diagnostic) { + return false; + } + this.deps.emitExternalFallbackRequested({ + player: target, + playback: this.deps.resolvedPlayback(), + diagnostic, + trackLaunch, + }); + return true; + } + ); + return; + } + recoverySession.beginPlayerSwitch(target, this.deps.resolvedIsLive()); + } + + retryPlayback(): void { + if (!this.deps.recoverySession.beginRetry()) { + return; + } + this.deps.reloadToken.update((value) => value + 1); + } + + syncSession(): void { + const sessionKey = this.deps.playbackSessionKey(); + this.deps.externalRecovery.syncSession(sessionKey); + if ( + untracked(() => this.deps.recoverySession.syncSession(sessionKey)) + ) { + this.clearDiagnostic(); + } + } + + clearDiagnostic(): void { + this.diagnosticOwnerToken.set(null); + this.playbackDiagnostic.set(null); + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 7fac576c5..f0be45979 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -48,13 +48,10 @@ import { ownsPlaybackApplication, type PlaybackApplicationOwnership, } from './web-player-application-ownership'; -import { - createWebPlayerApplicationState, - type WebPlayerApplicationToken, -} from './web-player-application-state'; +import { createWebPlayerApplicationState } from './web-player-application-state'; import { resolveWebPlayerMediaTitle } from './web-player-playback-state'; +import { WebPlayerRecoveryController } from './web-player-recovery-controller'; import { - createWebPlayerRecommendations, isPlaybackExternallyTransferable, resolveRenderableWebPlayer, toInlinePlaybackPlayer, @@ -130,7 +127,6 @@ export class WebPlayerViewComponent implements OnDestroy { () => this.settingsStore.showCaptions?.() ?? false ); readonly reloadToken = signal(0); - readonly playbackDiagnostic = signal(null); readonly externalRecoveryState = this.externalRecovery.states; readonly externalRecoveryPending = this.externalRecovery.pending; readonly recoveryPending = computed( @@ -173,68 +169,52 @@ export class WebPlayerViewComponent implements OnDestroy { readonly resolvedIsLive = this.applicationState.isLive; readonly playbackSourceRevisionToken = this.applicationState.sourceRevision; readonly playbackApplicationToken = this.applicationState.token; - // Diagnostic ownership follows raw intent so an old action disappears; - // the application effect then clears its backing state before handoff. - // Keep this token opaque: it must never retain playback payload fields. - private readonly playbackDiagnosticIntentToken = - computed(() => { - if (this.playback() === null) { - void this.streamUrl(); - void this.startTime(); - } - void this.selectedPlayer(); - void this.reloadToken(); - return Symbol(); - }); - private readonly playbackDiagnosticOwnerToken = - signal(null); + 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.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, + 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 visiblePlaybackDiagnostic = computed(() => - this.selectedPlayer() === VideoPlayer.EmbeddedMpv || - this.playbackDiagnosticOwnerToken() !== - this.playbackDiagnosticIntentToken() - ? null - : this.playbackDiagnostic() - ); readonly playbackInteractionEnabled = computed( () => this.visiblePlaybackDiagnostic() === null ); readonly resolvedMediaTitle = computed(() => resolveWebPlayerMediaTitle(this.mediaTitle(), this.resolvedPlayback()) ); - readonly playbackExternallyTransferable = computed(() => - isPlaybackExternallyTransferable(this.resolvedPlayback()) - ); readonly recordingFolder = computed( () => this.settingsStore.recordingFolder?.() ?? '' ); get supportsManagedExternalPlayers(): boolean { return this.runtime.supportsManagedExternalPlayers; } - readonly recommendations = computed(() => { - const binding = this.activeBinding(); - const token = this.playbackApplicationToken(); - return createWebPlayerRecommendations({ - diagnostic: this.visiblePlaybackDiagnostic(), - binding: - binding && this.applicationHandoff.owns(binding, token) - ? binding - : null, - attemptedTargets: this.recoverySession.attemptedTargets(), - externalStates: this.externalRecoveryState(), - managedExternalPlayersAvailable: - this.runtime.supportsManagedExternalPlayers, - playbackExternallyTransferable: - this.playbackExternallyTransferable(), - isLive: this.resolvedIsLive(), - alternativeSourceCount: this.alternativeSources().length, - }); - }); readonly renderedApplications = computed< readonly PlaybackApplicationOwnership[] >(() => { @@ -264,8 +244,8 @@ export class WebPlayerViewComponent implements OnDestroy { effect(() => { // Session sync may clear a temporary player override. Run it before // tracking intent so that reset is folded into this application. - this.syncRecoverySession(); - void this.playbackDiagnosticIntentToken(); + this.recovery.syncSession(); + void this.recovery.diagnosticIntentToken(); const sourceRevision = this.playbackSourceRevisionToken(); untracked(() => this.recoverySession.syncSourceRevision(sourceRevision) @@ -278,7 +258,7 @@ export class WebPlayerViewComponent implements OnDestroy { const target = toInlinePlaybackPlayer(selectedPlayer); this.channel = undefined; this.vjsOptions = undefined; - this.clearPlaybackDiagnostic(); + this.recovery.clearDiagnostic(); if (target === null) { this.applicationHandoff.release(); this.recoverySession.clearPlaybackBinding(); @@ -311,34 +291,7 @@ export class WebPlayerViewComponent implements OnDestroy { issue: PlaybackDiagnostic | null, binding: PlaybackBinding ): void { - this.syncRecoverySession(); - if (!this.recoverySession.accepts(binding)) { - return; - } - if ( - !this.applicationHandoff.owns( - binding, - this.playbackApplicationToken() - ) - ) { - this.applicationHandoff.invalidate(); - this.recoverySession.clearPlaybackBinding(); - this.clearPlaybackDiagnostic(); - return; - } - if (!issue) { - this.recoverySession.settle(binding); - this.clearPlaybackDiagnostic(); - return; - } - if (!this.recoverySession.recordFailure(binding)) { - return; - } - this.playbackDiagnosticOwnerToken.set( - this.playbackDiagnosticIntentToken() - ); - this.playbackDiagnostic.set(issue); - this.playbackFailed.emit(issue.code); + this.recovery.handlePlaybackIssue(issue, binding); } handleTimeUpdate( @@ -368,58 +321,10 @@ export class WebPlayerViewComponent implements OnDestroy { } requestRecommendedPlayer(target: PlaybackRecommendationTarget): void { - const diagnostic = this.visiblePlaybackDiagnostic(); - if (!diagnostic) { - return; - } - const available = this.recommendations().some( - (item) => item.action === 'player' && item.target === target - ); - if (!available) { - if (target !== 'mpv' && target !== 'vlc') { - this.recoverySession.recordInlineAttempt(target); - } - return; - } - if (target === 'mpv' || target === 'vlc') { - this.externalRecovery.request( - target, - () => this.recoverySession.recordExternalAttempt(target), - (trackLaunch) => { - if (this.visiblePlaybackDiagnostic() !== diagnostic) { - return false; - } - this.externalFallbackRequested.emit({ - player: target, - playback: this.resolvedPlayback(), - diagnostic, - trackLaunch, - }); - return true; - } - ); - return; - } - this.recoverySession.beginPlayerSwitch(target, this.resolvedIsLive()); + this.recovery.requestRecommendedPlayer(target); } retryPlayback(): void { - if (!this.recoverySession.beginRetry()) { - return; - } - this.reloadToken.update((value) => value + 1); - } - - private syncRecoverySession(): void { - const sessionKey = this.playbackSessionKey(); - this.externalRecovery.syncSession(sessionKey); - if (untracked(() => this.recoverySession.syncSession(sessionKey))) { - this.clearPlaybackDiagnostic(); - } - } - - private clearPlaybackDiagnostic(): void { - this.playbackDiagnosticOwnerToken.set(null); - this.playbackDiagnostic.set(null); + this.recovery.retryPlayback(); } }