mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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:
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)"
|
||||
/>
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
-21
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user