feat(playback): recommend recovery actions (#1374)

* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
This commit is contained in:
4gray authored and GitHub committed 2026-08-08 01:04:39 +02:00
1 parent 5e4f2ca3dd
commit fd96b85c19
176 files changed
+15228 -1908

No files matched your search

@@ -0,0 +1,500 @@
import {
InlinePlaybackPlayer,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import { PlaybackRecoverySession } from './playback-recovery-session';
describe('PlaybackRecoverySession', () => {
it('tracks failure, switch resume, and a reset across sessions', () => {
const session = new PlaybackRecoverySession();
expect(session.syncSession('movie-a')).toBe(true);
const firstBinding = session.beginPlayback(
InlinePlaybackPlayer.VideoJs
);
expect(session.recordFailure(firstBinding)).toBe(true);
expect([...session.attemptedTargets()]).toEqual([
InlinePlaybackPlayer.VideoJs,
]);
session.recordTimeUpdate({ currentTime: 42, duration: 120 }, false);
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
).toBe(true);
const replacementBinding = session.beginPlayback(
InlinePlaybackPlayer.Html5
);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.resumeStartTime(0, false)).toBe(42);
const previousAttempts = session.attemptedTargets();
expect(session.syncSession('movie-b')).toBe(true);
expect(session.attemptedTargets()).not.toBe(previousAttempts);
expect(session.attemptedTargets()).toEqual(new Set());
expect(session.temporaryPlayerOverride()).toBeNull();
expect(session.switchPending()).toBe(false);
expect(session.activeBinding()).toBeNull();
expect(session.recordFailure(firstBinding)).toBe(false);
expect(session.resumeStartTime(0, false)).toBe(0);
const newBinding = session.beginPlayback(
InlinePlaybackPlayer.ArtPlayer
);
expect(newBinding.generation).toBe(replacementBinding.generation + 2);
});
it('treats the same exact session key as a no-op', () => {
const session = new PlaybackRecoverySession();
session.syncSession('movie-a');
const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
session.recordTimeUpdate({ currentTime: 24, duration: 100 }, false);
const attempts = session.attemptedTargets();
expect(session.syncSession('movie-a')).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.activeBinding()).toBe(binding);
expect(session.accepts(binding)).toBe(true);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const switchAttempts = session.attemptedTargets();
expect(session.syncSession('movie-a')).toBe(false);
expect(session.attemptedTargets()).toBe(switchAttempts);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.switchPending()).toBe(true);
expect(session.resumeStartTime(5, false)).toBe(24);
expect(session.activeBinding()).toBeNull();
expect(session.accepts(binding)).toBe(false);
});
it('resets only the resume position when the source revision changes', () => {
const session = new PlaybackRecoverySession();
const firstRevision = Symbol();
session.syncSession('channel-a');
expect(session.syncSourceRevision(firstRevision)).toBe(true);
session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs);
session.recordTimeUpdate({ currentTime: 24, duration: 100 }, false);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const attempts = session.attemptedTargets();
expect(session.syncSourceRevision(firstRevision)).toBe(false);
expect(session.resumeStartTime(5, false)).toBe(24);
expect(session.syncSourceRevision(Symbol())).toBe(true);
expect(session.resumeStartTime(5, false)).toBe(5);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.switchPending()).toBe(true);
});
it('accepts an empty session key and compares keys exactly', () => {
const session = new PlaybackRecoverySession();
expect(session.syncSession('')).toBe(true);
expect(session.syncSession('')).toBe(false);
expect(session.syncSession(' ')).toBe(true);
});
it('advances the generation whenever playback begins', () => {
const session = new PlaybackRecoverySession();
session.syncSession('movie-a');
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
const first = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
const second = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(second.generation).toBe(first.generation + 1);
expect(session.accepts(first)).toBe(false);
expect(session.accepts(second)).toBe(true);
expect(session.attemptedTargets()).toEqual(
new Set([InlinePlaybackPlayer.ArtPlayer])
);
});
it('preserves session attempts across an alternative source binding', () => {
const session = new PlaybackRecoverySession();
session.syncSession('movie-a');
const first = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.recordFailure(first);
session.beginRetry();
const alternative = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.settle(first);
expect(session.accepts(first)).toBe(false);
expect(session.accepts(alternative)).toBe(true);
expect(session.switchPending()).toBe(true);
expect(session.attemptedTargets()).toEqual(
new Set([InlinePlaybackPlayer.VideoJs])
);
session.settle(alternative);
expect(session.switchPending()).toBe(false);
});
it('clears playback binding and prevents retry without resetting session data', () => {
const session = new PlaybackRecoverySession();
session.syncSession('movie-a');
session.recordTimeUpdate({ currentTime: 17, duration: 80 }, false);
session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const binding = session.beginPlayback(InlinePlaybackPlayer.Html5);
session.settle(binding);
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
session.clearPlaybackBinding();
expect(session.activeBinding()).toBeNull();
expect(session.accepts(binding)).toBe(false);
expect(session.beginRetry()).toBe(false);
expect(session.attemptedTargets()).toEqual(
new Set([
InlinePlaybackPlayer.Html5,
InlinePlaybackPlayer.ArtPlayer,
])
);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.resumeStartTime(0, false)).toBe(17);
const nextBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(nextBinding.generation).toBe(binding.generation + 2);
});
it('accepts only a matching active generation and target', () => {
const session = new PlaybackRecoverySession();
expect(
session.accepts({
generation: 0,
target: InlinePlaybackPlayer.VideoJs,
})
).toBe(false);
const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(session.accepts({ ...binding })).toBe(true);
expect(
session.accepts({
generation: binding.generation,
target: InlinePlaybackPlayer.Html5,
})
).toBe(false);
});
it('exposes an immutable active binding snapshot', () => {
const session = new PlaybackRecoverySession();
const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
const activeBinding = session.activeBinding();
expect(Object.isFrozen(binding)).toBe(true);
expect(activeBinding).toBe(binding);
expect(activeBinding).toEqual({
generation: 1,
target: InlinePlaybackPlayer.VideoJs,
});
expect(Object.keys(activeBinding ?? {})).toEqual([
'generation',
'target',
]);
expect(Object.isFrozen(activeBinding)).toBe(true);
expect('set' in session.attemptedTargets).toBe(false);
expect('set' in session.temporaryPlayerOverride).toBe(false);
expect('set' in session.switchPending).toBe(false);
expect('set' in session.activeBinding).toBe(false);
expect(
Reflect.set(
binding as unknown as Record<string, unknown>,
'target',
InlinePlaybackPlayer.Html5
)
).toBe(false);
expect(session.activeBinding()?.target).toBe(
InlinePlaybackPlayer.VideoJs
);
expect(
session.accepts({
generation: binding.generation,
target: InlinePlaybackPlayer.Html5,
})
).toBe(false);
});
it('copies attempted targets for every inline and external attempt', () => {
const session = new PlaybackRecoverySession();
const initial = session.attemptedTargets();
session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs);
const inline = session.attemptedTargets();
session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs);
const duplicate = session.attemptedTargets();
session.recordExternalAttempt('mpv');
const external = session.attemptedTargets();
expect(initial).toEqual(new Set());
expect(inline).toEqual(new Set([InlinePlaybackPlayer.VideoJs]));
expect(duplicate).toEqual(new Set([InlinePlaybackPlayer.VideoJs]));
expect(external).toEqual(
new Set<PlaybackRecommendationTarget>([
InlinePlaybackPlayer.VideoJs,
'mpv',
])
);
expect(inline).not.toBe(initial);
expect(duplicate).not.toBe(inline);
expect(external).not.toBe(duplicate);
});
it('copies attempts and clears pending for an accepted failure', () => {
const session = new PlaybackRecoverySession();
session.beginPlayback(InlinePlaybackPlayer.Html5);
session.beginRetry();
const binding = session.beginPlayback(InlinePlaybackPlayer.Html5);
const attempts = session.attemptedTargets();
expect(session.recordFailure(binding)).toBe(true);
expect(session.attemptedTargets()).not.toBe(attempts);
expect(session.attemptedTargets()).toEqual(
new Set([InlinePlaybackPlayer.Html5])
);
expect(session.switchPending()).toBe(false);
});
it('rejects the old binding throughout the switch pre-effect interval', () => {
const session = new PlaybackRecoverySession();
const oldBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
).toBe(true);
const attempts = session.attemptedTargets();
expect(session.activeBinding()).toBeNull();
expect(session.recordFailure(oldBinding)).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
session.settle(oldBinding);
expect(session.switchPending()).toBe(true);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
const replacement = session.beginPlayback(InlinePlaybackPlayer.Html5);
session.settle(replacement);
expect(session.switchPending()).toBe(false);
});
it('rejects the old binding throughout the retry pre-effect interval', () => {
const session = new PlaybackRecoverySession();
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
const oldBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(session.beginRetry()).toBe(true);
const attempts = session.attemptedTargets();
expect(session.activeBinding()).toBeNull();
expect(session.recordFailure(oldBinding)).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
session.settle(oldBinding);
expect(session.switchPending()).toBe(true);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.temporaryPlayerOverride()).toBeNull();
const replacement = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(session.recordFailure(replacement)).toBe(true);
expect(session.switchPending()).toBe(false);
expect(session.attemptedTargets()).not.toBe(attempts);
expect(session.attemptedTargets()).toEqual(
new Set([
InlinePlaybackPlayer.ArtPlayer,
InlinePlaybackPlayer.VideoJs,
])
);
});
it('leaves all state unchanged for a stale failure', () => {
const session = new PlaybackRecoverySession();
const stale = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const current = session.beginPlayback(InlinePlaybackPlayer.Html5);
const attempts = session.attemptedTargets();
expect(session.recordFailure(stale)).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.switchPending()).toBe(true);
expect(session.activeBinding()).toBe(current);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
});
it('settles only the matching binding without recording an attempt', () => {
const session = new PlaybackRecoverySession();
const stale = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const current = session.beginPlayback(InlinePlaybackPlayer.Html5);
const attempts = session.attemptedTargets();
session.settle(stale);
expect(session.switchPending()).toBe(true);
expect(session.attemptedTargets()).toBe(attempts);
session.settle(current);
expect(session.switchPending()).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
});
it('records a switch immediately and clears resume only for live playback', () => {
const session = new PlaybackRecoverySession();
session.recordTimeUpdate({ currentTime: 31, duration: 90 }, false);
const attempts = session.attemptedTargets();
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, true)
).toBe(true);
expect(session.attemptedTargets()).not.toBe(attempts);
expect(session.attemptedTargets()).toEqual(
new Set([InlinePlaybackPlayer.Html5])
);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.switchPending()).toBe(true);
expect(session.resumeStartTime(7, false)).toBe(7);
});
it('allows only the switch when a switch races a retry', () => {
const session = new PlaybackRecoverySession();
session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
).toBe(true);
const replacement = session.beginPlayback(InlinePlaybackPlayer.Html5);
const attempts = session.attemptedTargets();
expect(session.beginRetry()).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.activeBinding()).toBe(replacement);
expect(session.accepts(replacement)).toBe(true);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.switchPending()).toBe(true);
session.settle(replacement);
const bindingAfterRejectedRetry = session.beginPlayback(
InlinePlaybackPlayer.Html5
);
expect(bindingAfterRejectedRetry.generation).toBe(
replacement.generation + 1
);
});
it('allows only the retry when a retry races a switch', () => {
const session = new PlaybackRecoverySession();
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
session.beginPlayback(InlinePlaybackPlayer.VideoJs);
session.beginPlayerSwitch(InlinePlaybackPlayer.ArtPlayer, false);
const switched = session.beginPlayback(InlinePlaybackPlayer.ArtPlayer);
session.settle(switched);
expect(session.beginRetry()).toBe(true);
const replacement = session.beginPlayback(
InlinePlaybackPlayer.ArtPlayer
);
const attempts = session.attemptedTargets();
expect(
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
).toBe(false);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.activeBinding()).toBe(replacement);
expect(session.accepts(replacement)).toBe(true);
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.ArtPlayer
);
expect(session.switchPending()).toBe(true);
session.settle(replacement);
const bindingAfterRejectedSwitch = session.beginPlayback(
InlinePlaybackPlayer.ArtPlayer
);
expect(bindingAfterRejectedSwitch.generation).toBe(
replacement.generation + 1
);
});
it('begins retry only with an active binding and preserves state', () => {
const session = new PlaybackRecoverySession();
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
expect(session.beginRetry()).toBe(false);
const initial = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
expect(initial.generation).toBe(1);
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
const switched = session.beginPlayback(InlinePlaybackPlayer.Html5);
session.settle(switched);
const attempts = session.attemptedTargets();
expect(session.beginRetry()).toBe(true);
expect(session.attemptedTargets()).toBe(attempts);
expect(session.activeBinding()).toBeNull();
expect(session.temporaryPlayerOverride()).toBe(
InlinePlaybackPlayer.Html5
);
expect(session.beginRetry()).toBe(false);
});
it.each([
[{ currentTime: 9, duration: 20 }, true],
[{ currentTime: Number.NaN, duration: 20 }, false],
[{ currentTime: Number.POSITIVE_INFINITY, duration: 20 }, false],
[{ currentTime: Number.NEGATIVE_INFINITY, duration: 20 }, false],
[{ currentTime: -1, duration: 20 }, false],
])('ignores invalid or live time update %#', (event, isLive) => {
const session = new PlaybackRecoverySession();
session.recordTimeUpdate({ currentTime: 13, duration: 40 }, false);
session.recordTimeUpdate(event, isLive);
expect(session.resumeStartTime(3, false)).toBe(13);
});
it('stores any finite non-negative VOD time without interpreting duration', () => {
const session = new PlaybackRecoverySession();
session.recordTimeUpdate(
{ currentTime: 0, duration: Number.NaN },
false
);
expect(session.resumeStartTime(8, false)).toBe(0);
session.recordTimeUpdate({ currentTime: 75, duration: 10 }, false);
expect(session.resumeStartTime(8, false)).toBe(75);
});
it('always returns zero for live and otherwise preserves the host start time', () => {
const session = new PlaybackRecoverySession();
expect(session.resumeStartTime(19, false)).toBe(19);
expect(session.resumeStartTime(19, true)).toBe(0);
session.recordTimeUpdate({ currentTime: 44, duration: 100 }, false);
expect(session.resumeStartTime(19, true)).toBe(0);
expect(session.resumeStartTime(19, false)).toBe(44);
});
it('does not let stale callbacks clear pending for a new session', () => {
const stale = new PlaybackRecoverySession();
stale.syncSession('movie-a');
const oldBinding = stale.beginPlayback(InlinePlaybackPlayer.VideoJs);
stale.syncSession('movie-b');
stale.beginPlayback(InlinePlaybackPlayer.VideoJs);
stale.beginRetry();
stale.settle(oldBinding);
expect(stale.switchPending()).toBe(true);
expect(stale.recordFailure(oldBinding)).toBe(false);
expect(stale.switchPending()).toBe(true);
});
});
@@ -0,0 +1,174 @@
import { signal, type Signal } from '@angular/core';
import type {
InlinePlaybackPlayer,
PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
export interface PlaybackBinding {
readonly generation: number;
readonly target: InlinePlaybackPlayer;
}
export class PlaybackRecoverySession {
private readonly attemptedTargetsState = signal<
ReadonlySet<PlaybackRecommendationTarget>
>(new Set());
private readonly temporaryPlayerOverrideState =
signal<InlinePlaybackPlayer | null>(null);
private readonly switchPendingState = signal(false);
private readonly activeBindingState = signal<PlaybackBinding | null>(null);
private readonly sessionKey = signal<string | null>(null);
private readonly generation = signal(0);
private readonly resumePosition = signal<number | null>(null);
private sourceRevision: symbol | null = null;
public readonly attemptedTargets: Signal<
ReadonlySet<PlaybackRecommendationTarget>
> = this.attemptedTargetsState.asReadonly();
public readonly temporaryPlayerOverride: Signal<InlinePlaybackPlayer | null> =
this.temporaryPlayerOverrideState.asReadonly();
public readonly switchPending: Signal<boolean> =
this.switchPendingState.asReadonly();
public readonly activeBinding: Signal<PlaybackBinding | null> =
this.activeBindingState.asReadonly();
syncSession(key: string): boolean {
if (this.sessionKey() === key) {
return false;
}
this.sessionKey.set(key);
this.attemptedTargetsState.set(new Set());
this.temporaryPlayerOverrideState.set(null);
this.switchPendingState.set(false);
this.resumePosition.set(null);
this.sourceRevision = null;
this.activeBindingState.set(null);
this.generation.update((generation) => generation + 1);
return true;
}
syncSourceRevision(revision: symbol): boolean {
if (this.sourceRevision === revision) {
return false;
}
this.sourceRevision = revision;
this.resumePosition.set(null);
return true;
}
beginPlayback(target: InlinePlaybackPlayer): PlaybackBinding {
const generation = this.generation() + 1;
const binding: PlaybackBinding = Object.freeze({
generation,
target,
});
this.generation.set(generation);
this.activeBindingState.set(binding);
return binding;
}
clearPlaybackBinding(): void {
this.invalidatePlaybackBinding();
}
endPlayback(): void {
this.switchPendingState.set(false);
this.invalidatePlaybackBinding();
}
recordFailure(binding: PlaybackBinding): boolean {
if (!this.accepts(binding)) {
return false;
}
this.recordAttempt(binding.target);
this.switchPendingState.set(false);
return true;
}
recordInlineAttempt(target: InlinePlaybackPlayer): void {
this.recordAttempt(target);
}
recordExternalAttempt(target: ExternalPlayerName): void {
this.recordAttempt(target);
}
recordTimeUpdate(
event: { readonly currentTime: number; readonly duration: number },
isLive: boolean
): void {
if (
!isLive &&
Number.isFinite(event.currentTime) &&
event.currentTime >= 0
) {
this.resumePosition.set(event.currentTime);
}
}
beginPlayerSwitch(target: InlinePlaybackPlayer, isLive: boolean): boolean {
if (this.switchPendingState()) {
return false;
}
this.recordInlineAttempt(target);
if (isLive) {
this.resumePosition.set(null);
}
this.temporaryPlayerOverrideState.set(target);
this.switchPendingState.set(true);
this.invalidatePlaybackBinding();
return true;
}
beginRetry(): boolean {
if (this.switchPendingState() || this.activeBindingState() === null) {
return false;
}
this.switchPendingState.set(true);
this.invalidatePlaybackBinding();
return true;
}
settle(binding: PlaybackBinding): void {
if (this.accepts(binding)) {
this.switchPendingState.set(false);
}
}
resumeStartTime(inputStartTime: number, isLive: boolean): number {
if (isLive) {
return 0;
}
const position = this.resumePosition();
return position !== null && Number.isFinite(position) && position >= 0
? position
: inputStartTime;
}
accepts(binding: PlaybackBinding): boolean {
const activeBinding = this.activeBindingState();
return (
activeBinding !== null &&
activeBinding.generation === binding.generation &&
activeBinding.target === binding.target
);
}
private recordAttempt(target: PlaybackRecommendationTarget): void {
const attempts = new Set(this.attemptedTargetsState());
attempts.add(target);
this.attemptedTargetsState.set(attempts);
}
private invalidatePlaybackBinding(): void {
this.generation.update((generation) => generation + 1);
this.activeBindingState.set(null);
}
}
@@ -0,0 +1,116 @@
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import {
type PlaybackBinding,
PlaybackRecoverySession,
} from './playback-recovery-session';
import type { WebPlayerApplicationToken } from './web-player-application-state';
import {
createVideoJsOptions,
createWebPlayerChannel,
} from './web-player-playback-state';
export interface WebPlayerApplicationHandoff {
readonly channel: ReturnType<typeof createWebPlayerChannel>;
readonly vjsOptions: VideoPlayerOptions;
}
interface WebPlayerApplicationOwnership {
readonly binding: PlaybackBinding;
readonly token: WebPlayerApplicationToken;
}
export class WebPlayerApplicationHandoffCoordinator {
private headerScopeStreamUrl: string | null = null;
private activeOwnership: WebPlayerApplicationOwnership | null = null;
constructor(
private readonly streamHeaders: ElectronStreamHeadersService,
private readonly recoverySession: PlaybackRecoverySession
) {}
apply(
playback: ResolvedPortalPlayback,
isLive: boolean,
reloadToken: number,
binding: PlaybackBinding,
token: WebPlayerApplicationToken,
currentToken: () => WebPlayerApplicationToken,
accept: (handoff: WebPlayerApplicationHandoff) => void
): void {
this.activeOwnership = Object.freeze({ binding, token });
const headerSync = this.streamHeaders.apply(playback);
this.headerScopeStreamUrl = playback.streamUrl;
const acceptSource = (): void => {
const options = createVideoJsOptions({
streamUrl: playback.streamUrl,
isLive,
reloadToken,
});
accept({
channel: createWebPlayerChannel(playback),
vjsOptions: {
...options,
sources: options.sources.map((source) => ({ ...source })),
},
});
};
if (!headerSync) {
acceptSource();
return;
}
const handOff = (): void => {
if (this.ownsCurrentApplication(binding, token, currentToken)) {
acceptSource();
}
};
void headerSync.then(
(stillCurrent) => {
if (stillCurrent) {
handOff();
} else if (
this.ownsCurrentApplication(binding, token, currentToken)
) {
this.recoverySession.settle(binding);
}
},
() => {
if (this.ownsCurrentApplication(binding, token, currentToken)) {
this.recoverySession.settle(binding);
}
}
);
}
owns(binding: PlaybackBinding, token: WebPlayerApplicationToken): boolean {
if (!this.recoverySession.accepts(binding)) {
return false;
}
const active = this.activeOwnership;
return active?.binding === binding && active.token === token;
}
invalidate(): void {
this.activeOwnership = null;
}
private ownsCurrentApplication(
binding: PlaybackBinding,
token: WebPlayerApplicationToken,
currentToken: () => WebPlayerApplicationToken
): boolean {
return this.owns(binding, token) && currentToken() === token;
}
release(): void {
this.invalidate();
this.streamHeaders.clear(this.headerScopeStreamUrl);
this.headerScopeStreamUrl = null;
}
destroy(): void {
this.recoverySession.endPlayback();
this.release();
}
}
@@ -0,0 +1,58 @@
import { computed, type Signal } from '@angular/core';
import type {
ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import {
resolveWebPlayerIsLive,
resolveWebPlayerPlayback,
} from './web-player-playback-state';
export type WebPlayerApplicationToken = symbol;
export type WebPlayerSourceRevisionToken = symbol;
export function createWebPlayerApplicationState(sources: {
readonly playback: Signal<ResolvedPortalPlayback | null>;
readonly streamUrl: Signal<string>;
readonly title: Signal<string>;
readonly startTime: Signal<number>;
readonly selectedPlayer: Signal<VideoPlayer>;
readonly reloadToken: Signal<number>;
}): {
readonly playback: Signal<ResolvedPortalPlayback>;
readonly isLive: Signal<boolean>;
readonly sourceRevision: Signal<WebPlayerSourceRevisionToken>;
readonly token: Signal<WebPlayerApplicationToken>;
} {
const playback = computed(() => {
const explicit = sources.playback();
return (
explicit ??
resolveWebPlayerPlayback({
playback: null,
streamUrl: sources.streamUrl(),
title: sources.title(),
startTime: sources.startTime(),
})
);
});
const isLive = computed(() => {
const explicit = sources.playback();
return explicit ? resolveWebPlayerIsLive(explicit) : true;
});
const sourceRevision = computed<WebPlayerSourceRevisionToken>(() => {
if (sources.playback() === null) {
void sources.streamUrl();
void sources.startTime();
}
void isLive();
return Symbol();
});
const token = computed<WebPlayerApplicationToken>(() => {
void sourceRevision();
void sources.selectedPlayer();
void sources.reloadToken();
return Symbol();
});
return { playback, isLive, sourceRevision, token };
}
@@ -0,0 +1,175 @@
import { type ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import {
createVideoJsOptions,
createWebPlayerChannel,
resolveWebPlayerIsLive,
resolveWebPlayerMediaTitle,
resolveWebPlayerPlayback,
} from './web-player-playback-state';
describe('web player playback state', () => {
it('preserves an existing resolved playback payload', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://provider.example/movie/7.ts',
title: 'Movie Seven',
startTime: 42,
headers: { Authorization: 'Bearer secret' },
};
expect(
resolveWebPlayerPlayback({
playback,
streamUrl: 'https://fallback.example/stream',
title: 'Fallback',
startTime: 0,
})
).toBe(playback);
});
it('constructs fallback playback and uses the URL for a missing title', () => {
expect(
resolveWebPlayerPlayback({
playback: null,
streamUrl: 'https://example.com/live/1.ts',
title: '',
startTime: 19,
})
).toEqual({
streamUrl: 'https://example.com/live/1.ts',
title: 'https://example.com/live/1.ts',
startTime: 19,
});
});
it('uses explicit live metadata before inferring VOD from content info', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/movie.ts',
title: 'Movie',
isLive: true,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod',
},
};
expect(resolveWebPlayerIsLive(playback)).toBe(true);
expect(resolveWebPlayerIsLive({ ...playback, isLive: undefined })).toBe(
false
);
});
it('resolves an explicit media title, playback title, and raw URL fallback', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/movie.ts',
title: 'Movie',
};
expect(
resolveWebPlayerMediaTitle(
{ primary: 'Series', secondary: 'S01E02' },
playback
)
).toEqual({ primary: 'Series', secondary: 'S01E02' });
expect(resolveWebPlayerMediaTitle(null, playback)).toEqual({
primary: 'Movie',
secondary: null,
});
expect(
resolveWebPlayerMediaTitle(null, {
...playback,
title: playback.streamUrl,
})
).toBeNull();
});
it('prefers explicit HTTP metadata when constructing a channel', () => {
expect(
createWebPlayerChannel({
streamUrl: 'https://example.com/live/channel.m3u8',
title: 'Header Locked Channel',
thumbnail: 'https://example.com/logo.png',
userAgent: 'ProviderAgent/1.0',
referer: 'https://provider.example/ref',
origin: 'https://provider.example',
headers: {
'User-Agent': 'IgnoredAgent/1.0',
Referer: 'https://ignored.example/ref',
Origin: 'https://ignored.example',
},
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: { '0011': '2233' },
},
})
).toEqual(
expect.objectContaining({
id: 'https://example.com/live/channel.m3u8',
url: 'https://example.com/live/channel.m3u8',
name: 'Header Locked Channel',
tvg: expect.objectContaining({
name: 'Header Locked Channel',
logo: 'https://example.com/logo.png',
}),
http: {
'user-agent': 'ProviderAgent/1.0',
referrer: 'https://provider.example/ref',
origin: 'https://provider.example',
},
radio: 'false',
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: { '0011': '2233' },
},
})
);
});
it('falls back to case-insensitive HTTP headers', () => {
expect(
createWebPlayerChannel({
streamUrl: 'https://example.com/live/channel.m3u8',
title: '',
headers: {
'uSeR-aGeNt': 'HeaderAgent/1.0',
rEfErEr: 'https://headers.example/ref',
ORIGIN: 'https://headers.example',
},
}).http
).toEqual({
'user-agent': 'HeaderAgent/1.0',
referrer: 'https://headers.example/ref',
origin: 'https://headers.example',
});
});
it.each([
['https://example.com/live/index.m3u8', 'application/x-mpegURL'],
[
'https://example.com/play?extension=m3u8&token=signed',
'application/x-mpegURL',
],
['https://example.com/live/channel.ts', 'video/mp2t'],
['https://example.com/live.php?stream=123', 'video/mp2t'],
['https://example.com/archive/movie.mkv', 'video/matroska'],
[
'https://example.com/play?container=mkv&token=signed',
'video/matroska',
],
['https://example.com/archive/movie.mp4', 'video/mp4'],
])('selects the existing MIME type for %s', (streamUrl, type) => {
expect(
createVideoJsOptions({
streamUrl,
isLive: false,
reloadToken: 3,
})
).toEqual({
isLive: false,
reloadToken: 3,
sources: [{ src: streamUrl, type }],
});
});
});
@@ -0,0 +1,119 @@
import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util';
import type {
Channel,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { PlayerMediaTitle } from '../player-controls';
export function resolveWebPlayerPlayback(options: {
readonly playback: ResolvedPortalPlayback | null;
readonly streamUrl: string;
readonly title: string;
readonly startTime: number;
}): ResolvedPortalPlayback {
return (
options.playback ?? {
streamUrl: options.streamUrl,
title: options.title || options.streamUrl,
startTime: options.startTime,
}
);
}
export function resolveWebPlayerIsLive(
playback: ResolvedPortalPlayback
): boolean {
return typeof playback.isLive === 'boolean'
? playback.isLive
: !playback.contentInfo;
}
export function resolveWebPlayerMediaTitle(
explicit: PlayerMediaTitle | null,
playback: ResolvedPortalPlayback
): PlayerMediaTitle | null {
if (explicit?.primary?.trim()) {
return explicit;
}
const title = playback.title?.trim();
return !title || title === playback.streamUrl
? null
: { primary: title, secondary: null };
}
export function createWebPlayerChannel(
playback: ResolvedPortalPlayback
): Channel {
const title = playback.title || playback.streamUrl;
return {
id: playback.streamUrl,
url: playback.streamUrl,
name: title,
group: { title: '' },
tvg: {
id: '',
name: title,
url: '',
logo: playback.thumbnail ?? '',
rec: '',
},
http: {
referrer:
playback.referer ??
getHeaderValue(playback.headers, 'Referer') ??
'',
'user-agent':
playback.userAgent ??
getHeaderValue(playback.headers, 'User-Agent') ??
'',
origin:
playback.origin ??
getHeaderValue(playback.headers, 'Origin') ??
'',
},
radio: 'false',
drm: playback.drm,
};
}
export function createVideoJsOptions(options: {
readonly streamUrl: string;
readonly isLive: boolean;
readonly reloadToken: number;
}): {
readonly isLive: boolean;
readonly reloadToken: number;
readonly sources: readonly {
readonly src: string;
readonly type: string;
}[];
} {
const extension = getPlaybackMediaExtensionFromUrl(options.streamUrl);
const type =
extension === 'm3u' || extension === 'm3u8'
? 'application/x-mpegURL'
: extension === 'ts' || !extension
? 'video/mp2t'
: extension === 'mkv'
? 'video/matroska'
: 'video/mp4';
return {
isLive: options.isLive,
reloadToken: options.reloadToken,
sources: [{ src: options.streamUrl, type }],
};
}
function getHeaderValue(
headers: ResolvedPortalPlayback['headers'] | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
}
const matchingKey = Object.keys(headers).find(
(key) => key.toLowerCase() === name.toLowerCase()
);
return matchingKey ? headers[matchingKey] : undefined;
}
@@ -0,0 +1,82 @@
import {
InlinePlaybackPlayer,
createPlaybackTargetCapabilities,
recommendPlaybackRecovery,
resolvePlaybackSourceKind,
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import {
VideoPlayer,
type ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { PlaybackBinding } from './playback-recovery-session';
export function createWebPlayerRecommendations(options: {
readonly diagnostic: PlaybackDiagnostic | null;
readonly binding: PlaybackBinding | null;
readonly attemptedTargets: ReadonlySet<PlaybackRecommendationTarget>;
readonly managedExternalPlayersAvailable: boolean;
readonly playbackExternallyTransferable: boolean;
readonly isLive: boolean;
readonly alternativeSourceCount: number;
}): readonly PlaybackRecommendation[] {
if (!options.diagnostic || !options.binding) {
return [];
}
const sourceKind = resolvePlaybackSourceKind(options.diagnostic);
return recommendPlaybackRecovery({
diagnostic: options.diagnostic,
activeTarget: options.binding.target,
attemptedTargets: options.attemptedTargets,
targetCapabilities: createPlaybackTargetCapabilities({
sourceKind,
managedExternalPlayersAvailable:
options.managedExternalPlayersAvailable,
}),
source: {
kind: sourceKind,
isLive: options.isLive,
drm: options.playbackExternallyTransferable
? 'none'
: 'untransferable',
externalTransferable: options.playbackExternallyTransferable,
},
alternativeSourceCount: options.alternativeSourceCount,
});
}
export function isPlaybackExternallyTransferable(
playback: ResolvedPortalPlayback
): boolean {
return playback.drm === undefined;
}
export function toVideoPlayer(target: InlinePlaybackPlayer): VideoPlayer {
switch (target) {
case InlinePlaybackPlayer.VideoJs:
return VideoPlayer.VideoJs;
case InlinePlaybackPlayer.Html5:
return VideoPlayer.Html5Player;
case InlinePlaybackPlayer.ArtPlayer:
return VideoPlayer.ArtPlayer;
}
}
export function toInlinePlaybackPlayer(
player: VideoPlayer
): InlinePlaybackPlayer | null {
switch (player) {
case VideoPlayer.VideoJs:
return InlinePlaybackPlayer.VideoJs;
case VideoPlayer.Html5Player:
return InlinePlaybackPlayer.Html5;
case VideoPlayer.ArtPlayer:
return InlinePlaybackPlayer.ArtPlayer;
case VideoPlayer.EmbeddedMpv:
case VideoPlayer.MPV:
case VideoPlayer.VLC:
return null;
}
}
@@ -1,217 +0,0 @@
import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
getLikelyBrowserUnsupportedCodecLabels,
} from '../playback-diagnostics/playback-diagnostics.util';
export type PlaybackDiagnosticDetail = {
readonly labelKey: string;
readonly value: string;
};
export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
return `${getDiagnosticTranslationBase(issue)}.TITLE`;
}
export function getDiagnosticDescriptionKey(
issue: PlaybackDiagnostic,
supportsManagedExternalPlayers: boolean
): string {
if (
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
!supportsManagedExternalPlayers
) {
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION';
}
return `${getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
}
export function getDiagnosticMeta(issue: PlaybackDiagnostic): string {
if (issue.httpStatus !== undefined) {
return `HTTP ${issue.httpStatus}`;
}
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
if (codecs) {
return codecs;
}
return issue.container || issue.mimeType || '';
}
export function getDiagnosticCodecHint(issue: PlaybackDiagnostic): string {
return getLikelyBrowserUnsupportedCodecLabels(issue).join(', ');
}
export function getDiagnosticDetails(
issue: PlaybackDiagnostic
): readonly PlaybackDiagnosticDetail[] {
return [
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
value: issue.code,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: formatPlayer(issue.player),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: formatDiagnosticSource(issue.source),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
value: issue.container,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE',
value: issue.mimeType ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
value: issue.videoCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
value: issue.audioCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE',
value: issue.nativeErrorCode?.toString() ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value:
issue.vhs || issue.mpegTs || issue.shaka
? ''
: (issue.nativeErrorMessage ?? ''),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value: formatDiagnosticErrorDetails(issue),
},
].filter(({ value }) => value.trim().length > 0);
}
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
if (issue.mpegTs) {
return [
`stage=${issue.mpegTs.stage}`,
`failure=${issue.mpegTs.failure}`,
`type=${issue.mpegTs.engineType}`,
`details=${issue.mpegTs.engineDetails}`,
`disposition=${issue.mpegTs.disposition}`,
issue.mpegTs.httpStatus === undefined
? ''
: `HTTP ${issue.mpegTs.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.shaka) {
return [
`stage=${issue.shaka.stage}`,
`failure=${issue.shaka.failure}`,
`severity=${issue.shaka.severity}`,
`category=${issue.shaka.category}`,
`code=${issue.shaka.engineCode}`,
`disposition=${issue.shaka.disposition}`,
issue.shaka.httpStatus === undefined
? ''
: `HTTP ${issue.shaka.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.vhs) {
return [
`stage=${issue.vhs.stage}`,
`type=${issue.vhs.engineType}`,
`code=${issue.vhs.mediaErrorCode}`,
`disposition=${issue.vhs.disposition}`,
issue.vhs.httpStatus === undefined
? ''
: `HTTP ${issue.vhs.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.hls) {
return [
`stage=${issue.hls.stage}`,
`failure=${issue.hls.failure}`,
`type=${issue.hls.engineType}`,
`details=${issue.hls.engineDetails}`,
`disposition=${issue.hls.disposition}`,
issue.hls.httpStatus === undefined
? ''
: `HTTP ${issue.hls.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
return [
issue.httpStatus !== undefined ? `HTTP ${issue.httpStatus}` : '',
issue.nativeErrorType ?? '',
issue.details ?? '',
]
.filter((value) => value.trim().length > 0)
.join(' · ');
}
function getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
switch (issue.code) {
case PlaybackDiagnosticCode.UnsupportedContainer:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER';
case PlaybackDiagnosticCode.UnsupportedCodec:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC';
case PlaybackDiagnosticCode.MediaDecodeError:
return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR';
case PlaybackDiagnosticCode.NetworkError:
return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR';
case PlaybackDiagnosticCode.BrowserAccessError:
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR';
case PlaybackDiagnosticCode.DrmOrEncryption:
return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION';
case PlaybackDiagnosticCode.UnknownPlaybackError:
default:
return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR';
}
}
function formatPlayer(player: PlaybackDiagnostic['player']): string {
switch (player) {
case 'videojs':
return 'Video.js';
case 'html5':
return 'HTML5';
case 'artplayer':
return 'ArtPlayer';
default:
return '';
}
}
function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string {
switch (source) {
case 'vhs':
return 'Video.js / VHS';
case 'hls':
return 'HLS.js';
case 'mpegts':
return 'mpegts.js';
case 'native':
return 'Native media element';
case 'shaka':
return 'Shaka Player';
case 'source':
return 'Stream metadata';
default:
return source;
}
}
@@ -1,278 +1,112 @@
@if (selectedPlayer() === 'videojs') {
@defer (on immediate) {
<!-- The source hand-off is deferred until the scoped Electron header
override is configured; mounting the player before `vjsOptions`
exists would fire a source-less (or credential-less) first load. -->
@if (vjsOptions) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
@for (application of renderedApplications(); track application.token) {
@if (application.binding; as binding) {
@if (binding.target === 'videojs') {
@defer (on immediate) {
@if (vjsOptions) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div
class="web-player-defer-placeholder"
aria-hidden="true"
></div>
}
} @else if (binding.target === 'html5') {
@defer (on immediate) {
@if (channel) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div
class="web-player-defer-placeholder"
aria-hidden="true"
></div>
}
} @else if (binding.target === 'artplayer') {
@defer (on immediate) {
@if (channel) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div
class="web-player-defer-placeholder"
aria-hidden="true"
></div>
}
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'html5') {
@defer (on immediate) {
@if (channel) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'artplayer') {
@defer (on immediate) {
@if (channel) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'embedded-mpv') {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @else {
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
} @else if (application.embeddedMpv) {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
}
@if (visiblePlaybackDiagnostic(); as issue) {
<section
class="web-player-diagnostic"
data-test-id="playback-diagnostic-banner"
role="status"
>
<div class="web-player-diagnostic__content">
<div class="web-player-diagnostic__badge">
<mat-icon aria-hidden="true">warning</mat-icon>
<span>{{ getDiagnosticTitleKey(issue) | translate }}</span>
</div>
<h2 class="web-player-diagnostic__headline">
{{ diagnosticHeadlineKey() | translate }}
</h2>
<div class="web-player-diagnostic__description">
{{ getDiagnosticDescriptionKey(issue) | translate }}
</div>
@if (getDiagnosticMeta(issue); as meta) {
<div class="web-player-diagnostic__meta">
{{ meta }}
</div>
}
@if (getDiagnosticCodecHint(issue); as codecHint) {
<div
class="web-player-diagnostic__codec-hint"
data-test-id="playback-codec-hint"
>
{{
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
| translate: { codecs: codecHint }
}}
</div>
}
@if (alternativeSources().length > 0) {
<div
class="web-player-diagnostic__alternatives"
data-test-id="playback-alternative-sources"
>
<h3 class="web-player-diagnostic__alternatives-title">
{{
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
| translate
}}
</h3>
@for (source of visibleAlternatives(); track source.id) {
<app-vod-source-row
[source]="source"
[showPin]="false"
(playRequested)="alternativeSourceRequested.emit($event)"
(checkRequested)="sourceCheckRequested.emit($event)"
/>
}
@if (hiddenAlternativeCount() > 0) {
<p class="web-player-diagnostic__alternatives-more">
{{
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
| translate
: { count: hiddenAlternativeCount() }
}}
</p>
}
</div>
}
@if (canShowExternalFallbackActions()) {
<div
class="web-player-diagnostic__player-actions"
aria-label="External player fallbacks"
>
<button
class="web-player-diagnostic__player-card web-player-diagnostic__player-card--primary"
type="button"
data-test-id="playback-fallback-mpv"
(click)="requestExternalFallback('mpv')"
>
<mat-icon aria-hidden="true">open_in_new</mat-icon>
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV'
| translate
}}
</span>
<span class="web-player-diagnostic__player-hint">
{{
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV_HINT'
| translate
}}
</span>
</span>
</button>
<button
class="web-player-diagnostic__player-card"
type="button"
data-test-id="playback-fallback-vlc"
(click)="requestExternalFallback('vlc')"
>
<mat-icon aria-hidden="true">open_in_new</mat-icon>
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC'
| translate
}}
</span>
<span class="web-player-diagnostic__player-hint">
{{
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC_HINT'
| translate
}}
</span>
</span>
</button>
</div>
}
<div class="web-player-diagnostic__utilities">
<button
class="web-player-diagnostic__utility-button"
type="button"
[cdkCopyToClipboard]="resolvedPlayback().streamUrl"
[matTooltip]="resolvedPlayback().streamUrl"
data-test-id="playback-copy-url"
>
<mat-icon aria-hidden="true">content_copy</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' | translate }}
</button>
<span
class="web-player-diagnostic__divider"
aria-hidden="true"
></span>
<button
class="web-player-diagnostic__utility-button"
type="button"
data-test-id="playback-retry"
(click)="retryPlayback()"
>
<mat-icon aria-hidden="true">refresh</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY' | translate }}
</button>
<span
class="web-player-diagnostic__divider"
aria-hidden="true"
></span>
<details
class="web-player-diagnostic__details"
data-test-id="playback-diagnostic-details"
>
<summary>
<mat-icon aria-hidden="true">expand_more</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }}
</summary>
<dl>
@for (
detail of getDiagnosticDetails(issue);
track detail.labelKey
) {
<div class="web-player-diagnostic__detail-row">
<dt>{{ detail.labelKey | translate }}</dt>
<dd>{{ detail.value }}</dd>
</div>
}
</dl>
</details>
</div>
</div>
</section>
<app-playback-diagnostic-panel
[diagnostic]="issue"
[recommendations]="recommendations()"
[playback]="resolvedPlayback()"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[playbackExternallyTransferable]="playbackExternallyTransferable()"
[alternativeSources]="alternativeSources()"
[pending]="recoveryPending()"
(retryRequested)="retryPlayback()"
(playerRequested)="requestRecommendedPlayer($event)"
(alternativeSourceRequested)="alternativeSourceRequested.emit($event)"
(sourceCheckRequested)="sourceCheckRequested.emit($event)"
/>
}
@@ -22,462 +22,3 @@ app-html-video-player {
height: 100%;
background: #000;
}
.web-player-diagnostic {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px);
overflow: auto;
background:
radial-gradient(
circle at 18% 18%,
rgb(255 176 76 / 6%),
transparent 28%
),
rgb(0 0 0 / 96%);
color: #f6f6f6;
}
.web-player-diagnostic__content {
width: min(100%, 720px);
}
.web-player-diagnostic__badge {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 8px;
padding: 5px 11px;
border: 1px solid rgb(255 174 66 / 42%);
border-radius: 6px;
background: rgb(255 148 35 / 15%);
color: #ffb24c;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.1;
text-transform: uppercase;
}
.web-player-diagnostic__badge mat-icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
font-size: 16px;
}
.web-player-diagnostic__badge span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__headline {
max-width: 660px;
margin: 20px 0 0;
color: #fff;
font-size: 1.65rem;
font-weight: 760;
letter-spacing: 0;
line-height: 1.05;
}
.web-player-diagnostic__description {
max-width: 760px;
margin-top: 12px;
color: rgb(255 255 255 / 73%);
font-size: 1rem;
line-height: 1.48;
}
.web-player-diagnostic__meta {
margin-top: 9px;
color: rgb(255 255 255 / 48%);
font-family: var(--app-monospace-font, monospace);
font-size: 0.82rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.web-player-diagnostic__codec-hint {
display: inline-block;
max-width: 100%;
margin-top: 12px;
padding: 4px 8px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 6px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
font-weight: 620;
line-height: 1.3;
overflow-wrap: anywhere;
}
// "Try another source" — the alternatives block that turns the error screen
// into a recovery point. The overlay is always dark, so these are literals
// rather than theme tokens, matching the rest of this diagnostic surface.
.web-player-diagnostic__alternatives {
width: min(420px, 100%);
margin: 20px auto 0;
padding: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
background: rgb(22 27 36 / 90%);
text-align: left;
// Scrolls itself rather than growing the whole overlay: otherwise a long
// list pushes the error headline and message off the top of the screen,
// which is precisely the information the user needs first.
max-height: min(46vh, 320px);
overflow-y: auto;
}
.web-player-diagnostic__alternatives-more {
margin: 0;
padding: 6px 12px 8px;
color: rgb(255 255 255 / 45%);
font-size: 0.75rem;
}
.web-player-diagnostic__alternatives-title {
margin: 0;
padding: 8px 12px 4px;
color: rgb(255 255 255 / 55%);
font-size: 0.594rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.web-player-diagnostic__player-actions {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
gap: 12px;
margin-top: 28px;
}
.web-player-diagnostic__player-card {
display: grid;
min-height: 60px;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 8px;
background: rgb(0 0 0 / 22%);
color: #f5f5f5;
cursor: pointer;
text-align: left;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.web-player-diagnostic__player-card:hover {
border-color: rgb(255 255 255 / 28%);
background: rgb(255 255 255 / 6%);
}
.web-player-diagnostic__player-card:active {
transform: translateY(1px);
}
.web-player-diagnostic__player-card:focus-visible,
.web-player-diagnostic__utility-button:focus-visible,
.web-player-diagnostic__details summary:focus-visible {
outline: 2px solid #ffb24c;
outline-offset: 3px;
}
.web-player-diagnostic__player-card--primary {
border-color: transparent;
background: #fff;
color: #151515;
}
.web-player-diagnostic__player-card--primary:hover {
border-color: transparent;
background: rgb(255 255 255 / 88%);
}
.web-player-diagnostic__player-card mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.web-player-diagnostic__player-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.web-player-diagnostic__player-label {
overflow: hidden;
font-size: 0.95rem;
font-weight: 760;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__player-hint {
overflow: hidden;
color: currentColor;
font-size: 0.78rem;
line-height: 1.1;
opacity: 0.62;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__utilities {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px 12px;
margin-top: 22px;
color: rgb(255 255 255 / 45%);
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.86rem;
font-weight: 640;
line-height: 1.2;
}
.web-player-diagnostic__utility-button:hover,
.web-player-diagnostic__details summary:hover {
color: rgb(255 255 255 / 76%);
}
.web-player-diagnostic__utility-button mat-icon,
.web-player-diagnostic__details summary mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.web-player-diagnostic__divider {
width: 1px;
height: 18px;
margin-top: 3px;
background: rgb(255 255 255 / 16%);
}
.web-player-diagnostic__details {
min-width: 0;
max-width: 100%;
}
.web-player-diagnostic__details summary {
list-style: none;
}
.web-player-diagnostic__details summary::-webkit-details-marker {
display: none;
}
.web-player-diagnostic__details[open] {
flex-basis: 100%;
}
.web-player-diagnostic__details dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 7px 14px;
max-width: min(100%, 640px);
margin: 12px 0 0;
padding: 12px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 8px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
line-height: 1.3;
}
.web-player-diagnostic__detail-row {
display: contents;
}
.web-player-diagnostic__detail-row dt {
color: rgb(255 255 255 / 48%);
font-weight: 700;
white-space: nowrap;
}
.web-player-diagnostic__detail-row dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
@container (max-width: 760px) {
.web-player-diagnostic {
align-items: flex-start;
padding: 22px 16px;
}
.web-player-diagnostic__badge {
white-space: normal;
}
.web-player-diagnostic__badge span {
white-space: normal;
}
.web-player-diagnostic__headline {
margin-top: 16px;
font-size: 1.48rem;
}
.web-player-diagnostic__utilities {
gap: 10px 12px;
}
.web-player-diagnostic__details dl {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 520px) {
.web-player-diagnostic {
align-items: flex-start;
padding: clamp(14px, 5cqi, 22px);
}
.web-player-diagnostic__content {
width: 100%;
}
.web-player-diagnostic__badge {
gap: 6px;
padding: 4px 8px;
font-size: 0.7rem;
white-space: normal;
}
.web-player-diagnostic__badge span {
white-space: nowrap;
}
.web-player-diagnostic__headline {
margin-top: 14px;
font-size: 1.35rem;
line-height: 1.08;
}
.web-player-diagnostic__description {
margin-top: 10px;
font-size: 0.9rem;
line-height: 1.38;
}
.web-player-diagnostic__meta {
margin-top: 8px;
font-size: 0.74rem;
}
.web-player-diagnostic__codec-hint {
margin-top: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__player-actions {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-top: 16px;
}
.web-player-diagnostic__player-card {
min-height: 52px;
padding: 10px 12px;
}
.web-player-diagnostic__utilities {
display: grid;
gap: 7px;
margin-top: 16px;
}
.web-player-diagnostic__divider {
display: none;
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
width: fit-content;
min-height: 22px;
font-size: 0.82rem;
}
.web-player-diagnostic__details[open] {
flex-basis: auto;
}
.web-player-diagnostic__details dl {
max-height: 260px;
overflow: auto;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
margin-top: 8px;
padding: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 340px) {
.web-player-diagnostic {
padding: 12px;
}
.web-player-diagnostic__headline {
font-size: 1.22rem;
}
.web-player-diagnostic__description {
font-size: 0.84rem;
}
.web-player-diagnostic__player-label {
font-size: 0.88rem;
}
}
@@ -29,7 +29,8 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -85,6 +86,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
PlaybackDiagnosticPanelComponent,
ClipboardModule,
MatButtonModule,
MatIconModule,
@@ -96,6 +98,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
.compileComponents();
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -124,6 +127,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
fixture.destroy();
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -197,11 +201,11 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
expect(artPlayer.showCaptions()).toBe(true);
expect(artPlayer.interactionEnabled()).toBe(true);
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(artPlayer.interactionEnabled()).toBe(false);
component.handlePlaybackIssue(null);
emitPlaybackIssue(null);
fixture.detectChanges();
expect(artPlayer.interactionEnabled()).toBe(true);
});
@@ -209,7 +213,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
it('disables HTML5 surface interaction while a diagnostic is visible', async () => {
const htmlPlayer = await renderHtmlPlayer();
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(component.playbackInteractionEnabled()).toBe(false);
@@ -219,43 +223,49 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
it('re-enables HTML5 interaction after retrying or clearing the issue', async () => {
const htmlPlayer = await renderHtmlPlayer();
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(htmlPlayer.interactionEnabled()).toBe(false);
component.retryPlayback();
fixture.detectChanges();
const retriedHtmlPlayer = fixture.debugElement.query(
By.directive(StubHtmlVideoPlayerComponent)
).componentInstance as StubHtmlVideoPlayerComponent;
expect(component.playbackInteractionEnabled()).toBe(true);
expect(htmlPlayer.interactionEnabled()).toBe(true);
expect(retriedHtmlPlayer.interactionEnabled()).toBe(true);
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(htmlPlayer.interactionEnabled()).toBe(false);
expect(retriedHtmlPlayer.interactionEnabled()).toBe(false);
component.handlePlaybackIssue(null);
emitPlaybackIssue(null);
fixture.detectChanges();
expect(component.playbackInteractionEnabled()).toBe(true);
expect(htmlPlayer.interactionEnabled()).toBe(true);
expect(retriedHtmlPlayer.interactionEnabled()).toBe(true);
});
it('disables and restores Video.js interaction around diagnostics', async () => {
const vjsPlayer = await renderVjsPlayer();
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(false);
component.retryPlayback();
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(true);
const retriedVjsPlayer = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
expect(retriedVjsPlayer.interactionEnabled()).toBe(true);
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(false);
expect(retriedVjsPlayer.interactionEnabled()).toBe(false);
component.handlePlaybackIssue(null);
emitPlaybackIssue(null);
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(true);
expect(retriedVjsPlayer.interactionEnabled()).toBe(true);
});
it.each([
@@ -269,17 +279,20 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
'uses the resolved value for Video.js effect and retry: %s',
(_label, metadata, expected) => {
const streamUrl = 'https://example.com/video.ts';
const setVjsOptions = jest.spyOn(component, 'setVjsOptions');
setPlayback(metadata, streamUrl);
fixture.detectChanges();
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
expect(component.vjsOptions).toEqual(
expect.objectContaining({ isLive: expected, reloadToken: 0 })
);
setVjsOptions.mockClear();
component.retryPlayback();
fixture.detectChanges();
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
expect(component.vjsOptions).toEqual(
expect.objectContaining({ isLive: expected, reloadToken: 1 })
);
}
);
@@ -336,6 +349,16 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
return fixture.debugElement.query(By.directive(StubArtPlayerComponent))
.componentInstance as StubArtPlayerComponent;
}
function emitPlaybackIssue(issue: PlaybackDiagnostic | null): void {
fixture.detectChanges();
const binding = component.activeBinding();
expect(binding).not.toBeNull();
if (!binding) {
throw new Error('Expected an active inline playback binding');
}
component.handlePlaybackIssue(issue, binding);
}
});
function createVodContentInfo() {
@@ -356,6 +379,5 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
player: 'html5',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: false,
};
}
@@ -21,7 +21,15 @@ import {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey as resolveDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from '../playback-diagnostic-panel/playback-diagnostic-view.util';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -141,6 +149,7 @@ describe('WebPlayerViewComponent', () => {
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
PlaybackDiagnosticPanelComponent,
// Real, not stubbed: the point of the test below is
// that this row's Check action reaches the host.
VodSourceRowComponent,
@@ -156,6 +165,7 @@ describe('WebPlayerViewComponent', () => {
storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs }));
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -236,7 +246,7 @@ describe('WebPlayerViewComponent', () => {
]);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
fixture.detectChanges();
const check = fixture.debugElement.query(
@@ -258,7 +268,7 @@ describe('WebPlayerViewComponent', () => {
);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -295,7 +305,7 @@ describe('WebPlayerViewComponent', () => {
const issue = createHttpDiagnostic();
fixture.detectChanges();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -307,8 +317,8 @@ describe('WebPlayerViewComponent', () => {
expect(banner.nativeElement.textContent).toContain('HTTP 404');
expect(mpvButton).toBeNull();
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(component.getDiagnosticDetails(issue)).toEqual(
expect(getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
@@ -318,13 +328,57 @@ describe('WebPlayerViewComponent', () => {
);
});
it.each([
['browser-access', createBrowserAccessDiagnostic],
['unsupported-codec', createUnsupportedCodecDiagnostic],
])(
'uses neutral %s guidance when ClearKey playback cannot be transferred',
(_diagnostic, createDiagnostic) => {
runtimeCapabilities.supportsManagedExternalPlayers = true;
fixture.componentRef.setInput('playback', {
streamUrl: 'https://provider.example/protected.mpd',
title: 'Protected stream',
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: {
'00112233445566778899aabbccddeeff':
'ffeeddccbbaa99887766554433221100',
},
},
});
fixture.detectChanges();
emitPlaybackIssue(createDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
By.css('[data-test-id="playback-diagnostic-banner"]')
);
expect(banner.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE'
);
expect(banner.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION'
);
expect(
banner.query(
By.css(
'[data-test-id="playback-fallback-mpv"], [data-test-id="playback-fallback-vlc"]'
)
)
).toBeNull();
}
);
it('renders only sanitized structured HLS evidence in technical details', () => {
const issue = createStructuredHlsDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
@@ -345,10 +399,10 @@ describe('WebPlayerViewComponent', () => {
it('renders only sanitized structured VHS evidence in technical details', () => {
const issue = createStructuredVhsDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
@@ -374,10 +428,10 @@ describe('WebPlayerViewComponent', () => {
it('renders only sanitized structured Shaka evidence in technical details', () => {
const issue = createStructuredShakaDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(details).toEqual(
@@ -404,13 +458,13 @@ describe('WebPlayerViewComponent', () => {
it('renders only sanitized structured mpegts evidence in technical details', () => {
const issue = createStructuredMpegTsDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = component.getDiagnosticDetails(issue);
const details = getDiagnosticDetails(issue);
const renderedDetails = details.map(({ value }) => value).join(' ');
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(getDiagnosticMeta(issue)).toBe('HTTP 404');
expect(details).toEqual(
expect.arrayContaining([
{
@@ -432,59 +486,6 @@ describe('WebPlayerViewComponent', () => {
expect(renderedDetails).not.toContain('response body');
});
it('keeps query-declared HLS streams on the HLS mime type', () => {
const streamUrl =
'https://example.com/play?extension=m3u8&token=signed';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'application/x-mpegURL',
},
]);
});
it('uses the Matroska mime type for MKV paths', () => {
const streamUrl = 'https://example.com/archive/movie.mkv';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/matroska',
},
]);
});
it('uses the Matroska mime type for query-declared MKV streams', () => {
const streamUrl = 'https://example.com/play?container=mkv&token=signed';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/matroska',
},
]);
});
it('treats web script playback URLs without declared media extension as MPEG-TS', () => {
const streamUrl = 'https://example.com/live.php?stream=123&token=x';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/mp2t',
},
]);
});
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', async () => {
const streamUrl = 'https://example.com/movie/123.ts';
fixture.componentRef.setInput('playback', {
@@ -596,6 +597,7 @@ describe('WebPlayerViewComponent', () => {
const pendingSettings = new Subject<unknown>();
storageMap.get.mockReturnValue(pendingSettings.asObservable());
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -627,11 +629,11 @@ describe('WebPlayerViewComponent', () => {
);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
component.playbackDiagnostic.set(createUnsupportedCodecDiagnostic());
fixture.detectChanges();
component.requestExternalFallback('mpv');
component.requestRecommendedPlayer('mpv');
expect(component.playbackDiagnostic()).toBeNull();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(
fixture.debugElement.query(
By.directive(StubEmbeddedMpvPlayerComponent)
@@ -796,10 +798,10 @@ describe('WebPlayerViewComponent', () => {
it('uses the PWA browser access diagnostic description key outside desktop', () => {
const issue = createBrowserAccessDiagnostic();
expect(component.getDiagnosticTitleKey(issue)).toBe(
expect(getDiagnosticTitleKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.TITLE'
);
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
expect(getDiagnosticDescriptionKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
});
@@ -808,14 +810,14 @@ describe('WebPlayerViewComponent', () => {
runtimeCapabilities.supportsManagedExternalPlayers = true;
const issue = createBrowserAccessDiagnostic();
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
expect(getDiagnosticDescriptionKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
});
it('uses an inline recovery headline when external fallback actions are unavailable', () => {
fixture.detectChanges();
component.handlePlaybackIssue(createNetworkDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -838,7 +840,7 @@ describe('WebPlayerViewComponent', () => {
fixture.detectChanges();
const issue = createUnsupportedCodecDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = fixture.debugElement.query(
@@ -851,8 +853,8 @@ describe('WebPlayerViewComponent', () => {
expect(details.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
);
expect(component.getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(component.getDiagnosticDetails(issue)).toEqual(
expect(getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
@@ -888,7 +890,7 @@ describe('WebPlayerViewComponent', () => {
expect.objectContaining({ reloadToken: 0 })
);
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
const retryButton = fixture.debugElement.query(
@@ -902,9 +904,12 @@ describe('WebPlayerViewComponent', () => {
retryButton.nativeElement.click();
fixture.detectChanges();
const retriedPlayer = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
expect(component.playbackDiagnostic()).toBeNull();
expect(player.options()).toEqual(
expect(retriedPlayer.options()).toEqual(
expect.objectContaining({ reloadToken: 1 })
);
expect(
@@ -1029,6 +1034,24 @@ describe('WebPlayerViewComponent', () => {
);
});
});
function emitPlaybackIssue(issue: PlaybackDiagnostic | null): void {
fixture.detectChanges();
const binding = component.activeBinding();
expect(binding).not.toBeNull();
if (!binding) {
throw new Error('Expected an active inline playback binding');
}
component.handlePlaybackIssue(issue, binding);
}
function getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return resolveDiagnosticDescriptionKey(
issue,
runtimeCapabilities.supportsManagedExternalPlayers,
true
);
}
});
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
@@ -1041,7 +1064,6 @@ function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: true,
};
}
@@ -1056,7 +1078,6 @@ function createBrowserAccessDiagnostic(): PlaybackDiagnostic {
audioCodecs: [],
videoCodecs: [],
details: 'blocked by CORS policy',
externalFallbackRecommended: true,
};
}
@@ -1071,7 +1092,6 @@ function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic {
audioCodecs: ['ac-3'],
videoCodecs: ['hvc1.1.6.L93.B0'],
details: 'manifestIncompatibleCodecsError',
externalFallbackRecommended: true,
};
}
@@ -1086,7 +1106,6 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
audioCodecs: [],
videoCodecs: [],
details: 'HttpStatusCodeInvalid {"code":456,"msg":"<none>"}',
externalFallbackRecommended: false,
};
}
@@ -1104,7 +1123,6 @@ function createHttpDiagnostic(): PlaybackDiagnostic {
nativeErrorMessage: 'source not supported',
httpStatus: 404,
nativeErrorType: 'networkrequestfailed',
externalFallbackRecommended: false,
};
}
@@ -1128,7 +1146,6 @@ function createStructuredHlsDiagnostic(): PlaybackDiagnostic {
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}
@@ -1154,7 +1171,6 @@ function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
stage: 'unknown',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}
@@ -1182,7 +1198,6 @@ function createStructuredShakaDiagnostic(): PlaybackDiagnostic {
failure: 'network',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}
@@ -1209,6 +1224,5 @@ function createStructuredMpegTsDiagnostic(): PlaybackDiagnostic {
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}
@@ -12,50 +12,52 @@ import {
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import {
Channel,
ResolvedPortalPlayback,
Settings,
type PlaybackDiagnostic,
type PlaybackDiagnosticCode,
type PlaybackFallbackRequest,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
STORE_KEY,
VideoPlayer,
type Channel,
type ResolvedPortalPlayback,
type Settings,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
/** How many recovery options the error screen shows before it stops helping. */
const ERROR_SCREEN_ALTERNATIVES = 5;
import { ArtPlayerComponent } from '../art-player/art-player.component';
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WEB_PLAYER_SHARED_CONTROLS_ENABLED,
} from '../player-controls';
import {
type PlaybackDiagnostic,
type PlaybackDiagnosticCode,
type PlaybackFallbackRequest,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
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 {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './web-player-view-diagnostics.utils';
type PlaybackBinding,
PlaybackRecoverySession,
} from './playback-recovery-session';
import { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff';
import {
createWebPlayerApplicationState,
type WebPlayerApplicationToken,
type WebPlayerSourceRevisionToken,
} from './web-player-application-state';
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
import {
createWebPlayerRecommendations,
isPlaybackExternallyTransferable,
toInlinePlaybackPlayer,
toVideoPlayer,
} from './web-player-recovery-policy';
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
@@ -64,24 +66,25 @@ function resolveWebPlayerSharedControls(): boolean {
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
}
interface PlaybackApplicationOwnership {
readonly binding: PlaybackBinding | null;
readonly embeddedMpv: boolean;
readonly isLive: boolean;
readonly sourceRevision: WebPlayerSourceRevisionToken;
readonly token: WebPlayerApplicationToken;
}
@Component({
selector: 'app-web-player-view',
templateUrl: './web-player-view.component.html',
styleUrls: ['./web-player-view.component.scss'],
host: {
class: 'web-player-view',
},
host: { class: 'web-player-view' },
imports: [
ArtPlayerComponent,
ClipboardModule,
EmbeddedMpvPlayerComponent,
HtmlVideoPlayerComponent,
MatButtonModule,
MatIconModule,
MatTooltipModule,
TranslatePipe,
PlaybackDiagnosticPanelComponent,
VjsPlayerComponent,
VodSourceRowComponent,
],
providers: [
{
@@ -92,307 +95,310 @@ function resolveWebPlayerSharedControls(): boolean {
encapsulation: ViewEncapsulation.None,
})
export class WebPlayerViewComponent implements OnDestroy {
storage = inject(StorageMap);
private readonly storage = inject(StorageMap);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly recoverySession = new PlaybackRecoverySession();
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);
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
streamUrl = input.required<string>();
title = input('');
playback = input<ResolvedPortalPlayback | null>(null);
startTime = input<number>(0);
volume = input<number>(1);
playerOverride = input<VideoPlayer | null>(null);
seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
/** Display-ready title lines for the fullscreen overlay; hosts with richer
* context (e.g. series name + episode label) pass it explicitly. */
mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
/**
* Alternative sources offered on the error screen, turning a dead end into
* a recovery point. Empty (the default) keeps the overlay exactly as it
* was for every non-multi-source host.
*/
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
readonly alternativeSourceRequested = output<string>();
/** The row's Check action, which has to reach the host that can probe. */
readonly sourceCheckRequested = output<string>();
/**
* A playback failure the host may be able to recover from by switching
* source. Emitted alongside showing the overlay, never instead of it: if
* the host does nothing, the user still sees the honest error.
*/
readonly playbackFailed = output<PlaybackDiagnosticCode>();
/**
* An error screen is a recovery point, not a catalogue. Offering fifty
* options here is worse than offering the best few — the full list stays
* one click away behind the player's own sources button.
*/
readonly visibleAlternatives = computed(() =>
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
);
readonly hiddenAlternativeCount = computed(() =>
Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES)
);
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
settings = toSignal(this.storage.get(STORE_KEY.Settings)) as Signal<
Settings | undefined
>;
/**
* Subtitle preference for the built-in web players. Read from the settings
* store instead of an input so every host (M3U, Xtream, Stalker, portal
* detail pages) gets it without having to wire it through — the missing
* bindings were why the setting looked like a no-op (#1155).
*/
readonly settings = toSignal(
this.storage.get(STORE_KEY.Settings)
) as Signal<Settings | undefined>;
readonly showCaptions = computed(
() => this.settingsStore.showCaptions?.() ?? false
);
channel!: Channel;
vjsOptions!: {
isLive: boolean;
reloadToken: number;
sources: { src: string; type: string }[];
};
readonly reloadToken = signal(0);
readonly playbackDiagnostic = signal<PlaybackDiagnostic | null>(null);
readonly recoveryPending = this.recoverySession.switchPending;
readonly activeBinding = this.recoverySession.activeBinding;
channel: Channel | undefined;
vjsOptions: VideoPlayerOptions | undefined;
readonly selectedPlayer = computed<VideoPlayer>(() => {
const temporary = this.recoverySession.temporaryPlayerOverride();
return temporary
? toVideoPlayer(temporary)
: (this.playerOverride() ??
this.settings()?.player ??
VideoPlayer.VideoJs);
});
private readonly applicationState = createWebPlayerApplicationState({
playback: this.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;
// 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 effectiveStartTime = computed(() =>
this.recoverySession.resumeStartTime(
this.startTime(),
this.resolvedIsLive()
)
);
readonly visiblePlaybackDiagnostic = computed(() =>
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
this.selectedPlayer() === VideoPlayer.EmbeddedMpv ||
this.playbackDiagnosticOwnerToken() !==
this.playbackDiagnosticIntentToken()
? null
: this.playbackDiagnostic()
);
readonly playbackInteractionEnabled = computed(
() => this.visiblePlaybackDiagnostic() === null
);
readonly canShowExternalFallbackActions = computed(
() =>
this.runtime.supportsManagedExternalPlayers &&
!!this.visiblePlaybackDiagnostic()?.externalFallbackRecommended
readonly resolvedMediaTitle = computed(() =>
resolveWebPlayerMediaTitle(this.mediaTitle(), this.resolvedPlayback())
);
readonly diagnosticHeadlineKey = computed(() =>
this.canShowExternalFallbackActions()
? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
: 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
readonly playbackExternallyTransferable = computed(() =>
isPlaybackExternallyTransferable(this.resolvedPlayback())
);
readonly resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
const playback = this.playback();
if (playback) {
return playback;
readonly recordingFolder = computed(
() => this.settings()?.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(),
managedExternalPlayersAvailable:
this.runtime.supportsManagedExternalPlayers,
playbackExternallyTransferable:
this.playbackExternallyTransferable(),
isLive: this.resolvedIsLive(),
alternativeSourceCount: this.alternativeSources().length,
});
});
readonly renderedApplications = computed<
readonly PlaybackApplicationOwnership[]
>(() => {
const binding = this.activeBinding();
const embeddedMpv =
this.selectedPlayer() === VideoPlayer.EmbeddedMpv && !binding;
if (!binding && !embeddedMpv) {
return [];
}
return {
streamUrl: this.streamUrl(),
title: this.title() || this.streamUrl(),
startTime: this.startTime(),
};
return [
Object.freeze({
binding,
embeddedMpv,
isLive: this.resolvedIsLive(),
sourceRevision: this.playbackSourceRevisionToken(),
token: this.playbackApplicationToken(),
}),
];
});
readonly resolvedIsLive = computed(() => {
const playback = this.resolvedPlayback();
return typeof playback.isLive === 'boolean'
? playback.isLive
: !playback.contentInfo;
});
readonly selectedPlayer = computed(
() =>
this.playerOverride() ??
this.settings()?.player ??
VideoPlayer.VideoJs
);
readonly resolvedMediaTitle = computed<PlayerMediaTitle | null>(() => {
const explicit = this.mediaTitle();
if (explicit?.primary?.trim()) {
return explicit;
}
const playback = this.resolvedPlayback();
const title = playback.title?.trim();
// resolvedPlayback() falls back to the stream URL as title; a raw URL
// is not a watchable overlay title.
if (!title || title === playback.streamUrl) {
return null;
}
return { primary: title, secondary: null };
});
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
/** Stream URL the currently configured Electron header override belongs to. */
private headerScopeStreamUrl: string | null = null;
private readonly streamHeaders = inject(ElectronStreamHeadersService);
constructor() {
effect(() => {
// Track player changes so stale browser diagnostics are cleared on switch.
this.selectedPlayer();
const playback = this.resolvedPlayback();
const isLive = this.resolvedIsLive();
this.playbackDiagnostic.set(null);
this.applyPlayback(playback, isLive);
// 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();
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 = undefined;
this.vjsOptions = undefined;
this.clearPlaybackDiagnostic();
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 = handoff.channel;
this.vjsOptions = handoff.vjsOptions;
}
);
});
}
ngOnDestroy(): void {
// Portal credentials must not outlive the playback session that
// needed them: dropping the scoped override here keeps only the
// playlist-level (unscoped) User-Agent/Referer defaults active. The
// service no-ops if a newer consumer already owns the override slot.
this.streamHeaders.clear(this.headerScopeStreamUrl);
this.applicationHandoff.destroy();
}
/**
* Configures the scoped Electron request headers BEFORE the stream source
* is handed to a player, so the very first media request already carries
* them — an auth-gated portal stream answers 403 without its
* Cookie/Authorization, and several engines treat that first failure as
* fatal. In the PWA there is no header bridge and the source applies
* synchronously, exactly as before.
*/
private applyPlayback(
playback: ResolvedPortalPlayback,
isLive: boolean
handlePlaybackIssue(
issue: PlaybackDiagnostic | null,
binding: PlaybackBinding
): void {
const headerSync = this.streamHeaders.apply(playback);
this.headerScopeStreamUrl = playback.streamUrl;
const handOff = (): void => {
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, isLive);
};
if (!headerSync) {
handOff();
this.syncRecoverySession();
if (!this.recoverySession.accepts(binding)) {
return;
}
void headerSync.then((stillCurrent) => {
if (stillCurrent) {
handOff();
}
});
}
setVjsOptions(streamUrl: string, isLive = true) {
const extension = getPlaybackMediaExtensionFromUrl(streamUrl);
const mimeType =
extension === 'm3u' || extension === 'm3u8'
? 'application/x-mpegURL'
: extension === 'ts' || !extension
? 'video/mp2t'
: extension === 'mkv'
? 'video/matroska'
: 'video/mp4';
this.vjsOptions = {
isLive,
reloadToken: untracked(() => this.reloadToken()),
sources: [{ src: streamUrl, type: mimeType }],
};
}
setChannel(playbackOrUrl: ResolvedPortalPlayback | string) {
const playback =
typeof playbackOrUrl === 'string'
? {
streamUrl: playbackOrUrl,
title: playbackOrUrl,
}
: playbackOrUrl;
this.channel = {
id: playback.streamUrl,
url: playback.streamUrl,
name: playback.title || playback.streamUrl,
group: { title: '' },
tvg: {
id: '',
name: playback.title || playback.streamUrl,
url: '',
logo: playback.thumbnail ?? '',
rec: '',
},
http: {
referrer:
playback.referer ??
this.getHeaderValue(playback.headers, 'Referer') ??
'',
'user-agent':
playback.userAgent ??
this.getHeaderValue(playback.headers, 'User-Agent') ??
'',
origin:
playback.origin ??
this.getHeaderValue(playback.headers, 'Origin') ??
'',
},
radio: 'false',
drm: playback.drm,
};
}
handlePlaybackIssue(issue: PlaybackDiagnostic | null): void {
if (this.selectedPlayer() === VideoPlayer.EmbeddedMpv) {
this.playbackDiagnostic.set(null);
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);
if (issue) {
this.playbackFailed.emit(issue.code);
}
this.playbackFailed.emit(issue.code);
}
requestExternalFallback(player: ExternalPlayerName): void {
handleTimeUpdate(
event: { currentTime: number; duration: number },
ownership: PlaybackApplicationOwnership
): void {
if (!this.ownsPlaybackApplication(ownership)) {
return;
}
this.recoverySession.recordTimeUpdate(event, ownership.isLive);
this.timeUpdate.emit(event);
}
requestRecommendedPlayer(target: PlaybackRecommendationTarget): void {
const diagnostic = this.visiblePlaybackDiagnostic();
if (!diagnostic) {
return;
}
this.externalFallbackRequested.emit({
player,
playback: this.resolvedPlayback(),
diagnostic,
});
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.recoverySession.recordExternalAttempt(target);
this.externalFallbackRequested.emit({
player: target,
playback: this.resolvedPlayback(),
diagnostic,
});
return;
}
this.recoverySession.beginPlayerSwitch(target, this.resolvedIsLive());
}
retryPlayback(): void {
const playback = this.resolvedPlayback();
this.playbackDiagnostic.set(null);
this.reloadToken.update((value) => value + 1);
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
}
readonly getDiagnosticTitleKey = getDiagnosticTitleKey;
readonly getDiagnosticMeta = getDiagnosticMeta;
readonly getDiagnosticCodecHint = getDiagnosticCodecHint;
readonly getDiagnosticDetails = getDiagnosticDetails;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return getDiagnosticDescriptionKey(
issue,
this.runtime.supportsManagedExternalPlayers
);
}
private getHeaderValue(
headers: ResolvedPortalPlayback['headers'] | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
if (!this.recoverySession.beginRetry()) {
return;
}
this.reloadToken.update((value) => value + 1);
}
const matchingKey = Object.keys(headers).find(
(key) => key.toLowerCase() === name.toLowerCase()
private syncRecoverySession(): void {
const sessionKey = this.playbackSessionKey();
if (untracked(() => this.recoverySession.syncSession(sessionKey))) {
this.clearPlaybackDiagnostic();
}
}
private clearPlaybackDiagnostic(): void {
this.playbackDiagnosticOwnerToken.set(null);
this.playbackDiagnostic.set(null);
}
private ownsPlaybackApplication(
ownership: PlaybackApplicationOwnership
): boolean {
if (
ownership.token !== this.playbackApplicationToken() ||
ownership.sourceRevision !== this.playbackSourceRevisionToken()
) {
return false;
}
if (ownership.binding) {
return (
!ownership.embeddedMpv &&
this.applicationHandoff.owns(ownership.binding, ownership.token)
);
}
return (
ownership.embeddedMpv &&
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
);
return matchingKey ? headers[matchingKey] : undefined;
}
}
@@ -1,5 +1,5 @@
import { Component, input, output } from '@angular/core';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
/**
* Player stand-ins for WebPlayerViewComponent specs. They mirror the real
@@ -7,7 +7,10 @@ import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagno
* without pulling in video.js, ArtPlayer or the Electron bridge.
*/
@Component({ selector: 'app-vjs-player', template: '' })
@Component({
selector: 'app-vjs-player',
template: '<div data-test-id="stub-vjs-player"></div>',
})
export class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly mediaTitle = input<unknown>(null);
@@ -23,7 +26,10 @@ export class StubVjsPlayerComponent {
readonly nextEpisodeRequested = output<void>();
}
@Component({ selector: 'app-html-video-player', template: '' })
@Component({
selector: 'app-html-video-player',
template: '<div data-test-id="stub-html-player"></div>',
})
export class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
@@ -40,7 +46,10 @@ export class StubHtmlVideoPlayerComponent {
readonly nextEpisodeRequested = output<void>();
}
@Component({ selector: 'app-art-player', template: '' })
@Component({
selector: 'app-art-player',
template: '<div data-test-id="stub-art-player"></div>',
})
export class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
@@ -57,7 +66,10 @@ export class StubArtPlayerComponent {
readonly nextEpisodeRequested = output<void>();
}
@Component({ selector: 'app-embedded-mpv-player', template: '' })
@Component({
selector: 'app-embedded-mpv-player',
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
})
export class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly mediaTitle = input<unknown>(null);