refactor(playback): extract web player diagnostic/recovery controller (#1444)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-15 11:40:22 +02:00
1 parent c723093890
commit 20857e34c2
2 files changed
+225 -131

No files matched your search

@@ -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<string>;
readonly playback: Signal<ResolvedPortalPlayback | null>;
readonly streamUrl: Signal<string>;
readonly startTime: Signal<number>;
readonly selectedPlayer: Signal<VideoPlayer>;
readonly reloadToken: WritableSignal<number>;
readonly playbackApplicationToken: Signal<WebPlayerApplicationToken>;
readonly resolvedPlayback: Signal<ResolvedPortalPlayback>;
readonly resolvedIsLive: Signal<boolean>;
readonly playbackExternallyTransferable: Signal<boolean>;
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<PlaybackDiagnostic | null>(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<WebPlayerApplicationToken>(() => {
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<WebPlayerApplicationToken | null>(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);
}
}
@@ -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<PlaybackDiagnostic | null>(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<WebPlayerApplicationToken>(() => {
if (this.playback() === null) {
void this.streamUrl();
void this.startTime();
}
void this.selectedPlayer();
void this.reloadToken();
return Symbol();
});
private readonly playbackDiagnosticOwnerToken =
signal<WebPlayerApplicationToken | null>(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();
}
}