mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user