feat(playback): switch temporarily to recommended players

This commit is contained in:
4gray committed 2026-08-04 21:27:33 +02:00
1 parent 5e489b537e
commit 051afe5c21
36 files changed
+2019 -1543

No files matched your search

@@ -29,8 +29,7 @@ describe('mpegts.js playback evidence', () => {
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
});
expect(mpegts.ErrorDetails).toEqual({
NETWORK_EXCEPTION:
MpegTsPlaybackEngineDetails.NetworkException,
NETWORK_EXCEPTION: MpegTsPlaybackEngineDetails.NetworkException,
NETWORK_STATUS_CODE_INVALID:
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout,
@@ -46,47 +45,25 @@ describe('mpegts.js playback evidence', () => {
});
it.each([
[
'NetworkError',
'HttpStatusCodeInvalid',
'loader',
'http',
],
['NetworkError', 'HttpStatusCodeInvalid', 'loader', 'http'],
['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'],
['NetworkError', 'Exception', 'loader', 'network'],
[
'NetworkError',
'UnrecoverableEarlyEof',
'loader',
'truncated-stream',
],
['NetworkError', 'UnrecoverableEarlyEof', 'loader', 'truncated-stream'],
['MediaError', 'FormatError', 'demux', 'format'],
['MediaError', 'FormatUnsupported', 'demux', 'format'],
['MediaError', 'CodecUnsupported', 'demux', 'codec'],
[
'MediaError',
'MediaMSEError',
'media-source',
'media-source',
],
])(
'maps %s + %s to %s/%s',
(engineType, engineDetails, stage, failure) => {
expect(
createMpegTsPlaybackEvidence(
engineType,
engineDetails,
undefined
)
).toEqual({
engineType,
engineDetails,
disposition: 'terminal',
stage,
failure,
});
}
);
['MediaError', 'MediaMSEError', 'media-source', 'media-source'],
])('maps %s + %s to %s/%s', (engineType, engineDetails, stage, failure) => {
expect(
createMpegTsPlaybackEvidence(engineType, engineDetails, undefined)
).toEqual({
engineType,
engineDetails,
disposition: 'terminal',
stage,
failure,
});
});
it.each([
['OtherError', 'CodecUnsupported'],
@@ -96,15 +73,18 @@ describe('mpegts.js playback evidence', () => {
['NetworkError', 'httpstatuscodeinvalid'],
[{ type: 'NetworkError' }, 'Exception'],
['MediaError', { details: 'FormatError' }],
])('keeps inconsistent or malformed %p + %p evidence unknown', (type, details) => {
const evidence = createMpegTsPlaybackEvidence(type, details, {
code: 503,
});
])(
'keeps inconsistent or malformed %p + %p evidence unknown',
(type, details) => {
const evidence = createMpegTsPlaybackEvidence(type, details, {
code: 503,
});
expect(evidence.stage).toBe('unknown');
expect(evidence.failure).toBe('unknown');
expect(evidence.httpStatus).toBeUndefined();
});
expect(evidence.stage).toBe('unknown');
expect(evidence.failure).toBe('unknown');
expect(evidence.httpStatus).toBeUndefined();
}
);
it.each([399, 600, 404.5, '404', null, undefined])(
'rejects invalid HTTP status %p',
@@ -163,45 +143,24 @@ describe('mpegts.js playback evidence', () => {
});
it.each([
['NetworkError', 'HttpStatusCodeInvalid', 'network-error', false],
['NetworkError', 'ConnectingTimeout', 'network-error', false],
['NetworkError', 'Exception', 'network-error', false],
[
'NetworkError',
'UnrecoverableEarlyEof',
'media-decode-error',
true,
],
['MediaError', 'FormatError', 'media-decode-error', true],
[
'MediaError',
'FormatUnsupported',
'unsupported-container',
true,
],
[
'MediaError',
'CodecUnsupported',
'unsupported-codec',
true,
],
['MediaError', 'MediaMSEError', 'media-decode-error', true],
['OtherError', 'Exception', 'unknown-playback-error', false],
])(
'classifies %s + %s as %s with fallback=%s',
(type, details, code, externalFallbackRecommended) => {
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, undefined),
METADATA
);
['NetworkError', 'HttpStatusCodeInvalid', 'network-error'],
['NetworkError', 'ConnectingTimeout', 'network-error'],
['NetworkError', 'Exception', 'network-error'],
['NetworkError', 'UnrecoverableEarlyEof', 'media-decode-error'],
['MediaError', 'FormatError', 'media-decode-error'],
['MediaError', 'FormatUnsupported', 'unsupported-container'],
['MediaError', 'CodecUnsupported', 'unsupported-codec'],
['MediaError', 'MediaMSEError', 'media-decode-error'],
['OtherError', 'Exception', 'unknown-playback-error'],
])('classifies %s + %s as %s', (type, details, code) => {
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, undefined),
METADATA
);
expect(issue.code).toBe(code);
expect(issue.externalFallbackRecommended).toBe(
externalFallbackRecommended
);
expect(issue.details).toBeUndefined();
}
);
expect(issue.code).toBe(code);
expect(issue.details).toBeUndefined();
});
it('preserves an exact HTTP failure without retaining provider details', () => {
const secret = 'mpegts-http-secret';
@@ -230,7 +189,6 @@ describe('mpegts.js playback evidence', () => {
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
})
);
expect(issue.details).toBeUndefined();
@@ -264,7 +264,6 @@ export interface PlaybackDiagnostic {
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
readonly externalFallbackRecommended: boolean;
}
export interface PlaybackFallbackRequest {
@@ -90,7 +90,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue?.externalFallbackRecommended).toBe(true);
expect(issue?.audioCodecs).toEqual(['ac-3']);
expect(issue?.videoCodecs).toEqual(['avc1.64001f']);
});
@@ -111,7 +110,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue?.externalFallbackRecommended).toBe(true);
});
it('classifies native decode and unsupported source errors without using network wording', () => {
@@ -133,11 +131,9 @@ describe('playback diagnostics', () => {
);
expect(decodeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(decodeIssue.externalFallbackRecommended).toBe(true);
expect(unsupportedSourceIssue.code).toBe(
PlaybackDiagnosticCode.UnsupportedContainer
);
expect(unsupportedSourceIssue.externalFallbackRecommended).toBe(true);
});
it('classifies MIME-only unsupported containers as container diagnostics', () => {
@@ -166,7 +162,6 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.container).toBe('mp2t');
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies native HTTP source failures from a safe status and error type', () => {
@@ -186,7 +181,6 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.httpStatus).toBe(404);
expect(issue.nativeErrorType).toBe('networkrequestfailed');
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies exact VHS network evidence without using provider text', () => {
@@ -210,7 +204,6 @@ describe('playback diagnostics', () => {
source: 'vhs',
httpStatus: undefined,
nativeErrorMessage: undefined,
externalFallbackRecommended: false,
vhs: {
engineType: 'networkrequestfailed',
mediaErrorCode: 4,
@@ -238,7 +231,6 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.source).toBe('vhs');
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.nativeErrorMessage).toBeUndefined();
expect(issue.vhs).toEqual({
engineType: 'streamingfailedtodecryptsegment',
@@ -280,7 +272,6 @@ describe('playback diagnostics', () => {
);
expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(vhsIssue.externalFallbackRecommended).toBe(false);
expect(vhsIssue.nativeErrorMessage).toBeUndefined();
expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
});
@@ -343,7 +334,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.externalFallbackRecommended).toBe(false);
expect(issue.httpStatus).toBeUndefined();
});
@@ -364,7 +354,6 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.httpStatus).toBeUndefined();
expect(issue.nativeErrorType).toBeUndefined();
expect(issue.externalFallbackRecommended).toBe(false);
});
it.each([
@@ -433,7 +422,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
it('retains structured HLS HTTP and stage evidence', () => {
@@ -464,7 +452,6 @@ describe('playback diagnostics', () => {
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
})
);
});
@@ -504,7 +491,6 @@ describe('playback diagnostics', () => {
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue?.httpStatus).toBeUndefined();
expect(issue?.externalFallbackRecommended).toBe(false);
});
it('classifies exact HLS decrypt evidence as DRM or encryption', () => {
@@ -523,7 +509,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue?.externalFallbackRecommended).toBe(true);
});
it('classifies exact HLS key load failures as network errors', () => {
@@ -542,7 +527,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
it.each([ErrorTypes.MEDIA_ERROR, ErrorTypes.MUX_ERROR])(
@@ -582,7 +566,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
it('classifies native CORS failures as browser access errors', () => {
@@ -599,7 +582,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe('browser-access-error');
expect(issue.externalFallbackRecommended).toBe(true);
});
it('keeps opaque native network failures generic when the browser hides access details', () => {
@@ -615,7 +597,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.externalFallbackRecommended).toBe(false);
});
it('does not guess mpegts browser access from exception messages', () => {
@@ -632,7 +613,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.externalFallbackRecommended).toBe(false);
expect(JSON.stringify(issue)).not.toContain(secret);
});
@@ -651,7 +631,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.mpegTs?.failure).toBe('truncated-stream');
expect(issue.details).toBeUndefined();
});
@@ -671,7 +650,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue.externalFallbackRecommended).toBe(true);
});
it('prefers stream extension query metadata over web script path extensions', () => {
@@ -209,9 +209,6 @@ export function createPlaybackDiagnostic(options: {
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
readonly externalFallbackRecommended?: boolean;
}): PlaybackDiagnostic {
const {
code,
@@ -246,9 +243,6 @@ export function createPlaybackDiagnostic(options: {
hls,
mpegTs,
shaka,
externalFallbackRecommended:
options.externalFallbackRecommended ??
isExternalFallbackRecommended(code),
};
}
@@ -322,16 +316,6 @@ function getNativeErrorType(errorType: unknown): string | undefined {
: undefined;
}
function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean {
return (
code === DiagnosticCode.UnsupportedContainer ||
code === DiagnosticCode.UnsupportedCodec ||
code === DiagnosticCode.MediaDecodeError ||
code === DiagnosticCode.BrowserAccessError ||
code === DiagnosticCode.DrmOrEncryption
);
}
function hasCodecs(metadata: PlaybackSourceMetadata): boolean {
return metadata.audioCodecs.length > 0 || metadata.videoCodecs.length > 0;
}
@@ -216,8 +216,5 @@ describe('createUnsupportedDrmDiagnostic', () => {
expect(issue.details).toBe('Unsupported DRM license configuration');
expect(JSON.stringify(issue)).not.toContain(secret);
expect(JSON.stringify(issue)).not.toContain('provider.example');
// MPV/VLC cannot receive KODIPROP license config, so the diagnostic
// must not offer them as a fallback.
expect(issue.externalFallbackRecommended).toBe(false);
});
});
@@ -88,9 +88,6 @@ export function createUnsupportedDrmDiagnostic(
source: DiagnosticSource.Shaka,
metadata,
details: 'Unsupported DRM license configuration',
// External MPV/VLC cannot receive the KODIPROP license config either,
// so offering them as a fallback would just fail differently.
externalFallbackRecommended: false,
});
}
@@ -37,7 +37,6 @@ function diagnostic(code: PlaybackDiagnosticCode): PlaybackDiagnostic {
container: '',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: false,
};
}
@@ -24,7 +24,6 @@ function diagnostic(
container: '',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: false,
...overrides,
};
}
@@ -590,7 +590,6 @@ describe('VideoPlayerComponent', () => {
container: 'm3u8',
audioCodecs: ['ac-3'],
videoCodecs: ['avc1.64001f'],
externalFallbackRecommended: true,
},
} satisfies PlaybackFallbackRequest);
@@ -619,7 +619,6 @@ describe('UnifiedLiveTabComponent', () => {
mimeType: '',
videoCodecs: [],
audioCodecs: [],
externalFallbackRecommended: false,
},
});
@@ -556,7 +556,6 @@ describe('StalkerLiveStreamLayoutComponent', () => {
mimeType: '',
videoCodecs: [],
audioCodecs: [],
externalFallbackRecommended: true,
},
};
@@ -93,7 +93,6 @@ describe('ArtPlayerComponent', () => {
code: 'unsupported-container',
source: 'native',
sourceUrl: 'https://example.com/archive/movie.mkv',
externalFallbackRecommended: true,
}),
]);
});
@@ -124,7 +123,6 @@ describe('ArtPlayerComponent', () => {
code: 'unsupported-codec',
source: 'hls',
sourceUrl: 'https://example.com/live/playlist.m3u8',
externalFallbackRecommended: true,
}),
]);
});
@@ -256,7 +254,6 @@ describe('ArtPlayerComponent', () => {
engineDetails: 'CodecUnsupported',
failure: 'codec',
}),
externalFallbackRecommended: true,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
@@ -154,7 +154,6 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
@@ -203,7 +203,6 @@ describe('HtmlVideoPlayerComponent', () => {
code: 'unsupported-container',
source: 'native',
sourceUrl: 'http://test.ts',
externalFallbackRecommended: true,
}),
]);
});
@@ -26,7 +26,6 @@ const DIAGNOSTIC: PlaybackDiagnostic = {
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: true,
};
const PLAYBACK: ResolvedPortalPlayback = {
@@ -21,7 +21,6 @@ describe('playback diagnostic view formatters', () => {
player: 'videojs',
audioCodecs: ['ac-3'],
videoCodecs: ['hvc1.1.6.L93.B0'],
externalFallbackRecommended: true,
};
it('preserves existing title, metadata, codec and detail formatting', () => {
@@ -160,7 +160,6 @@ describe('PortalInlinePlayerComponent', () => {
mimeType: '',
videoCodecs: [],
audioCodecs: [],
externalFallbackRecommended: true,
},
};
let forwarded: PlaybackFallbackRequest | undefined;
@@ -116,8 +116,6 @@ describe('ShakaVideoSession', () => {
stage: 'unknown',
failure: 'drm',
});
// Without KODIPROP config the DRM hint may still help externally.
expect(issues[0].externalFallbackRecommended).toBe(true);
// Critical errors end playback: the dead engine must be torn down.
expect(player.destroyCount).toBe(1);
expect(session.getPlayer()).toBeNull();
@@ -142,7 +140,7 @@ describe('ShakaVideoSession', () => {
expect(session.getPlayer()).toBe(player);
});
it('drops the external-fallback hint for every failure on ClearKey channels', async () => {
it('emits structured failures without owning ClearKey recovery policy', async () => {
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/enc.mpd', {
@@ -160,12 +158,6 @@ describe('ShakaVideoSession', () => {
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
// MPV/VLC never receive the KODIPROP license config, so they are not
// offered as a fallback for key failures on ClearKey channels.
expect(issues[0].externalFallbackRecommended).toBe(false);
// Non-DRM failures (media/codec/manifest/…) are equally unsolvable
// externally while the stream itself stays encrypted.
session.start(video, 'http://example.com/enc.mpd', {
licenseType: 'clearkey',
supported: true,
@@ -180,7 +172,6 @@ describe('ShakaVideoSession', () => {
expect(issues).toHaveLength(2);
expect(issues[1].code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(issues[1].externalFallbackRecommended).toBe(false);
});
it('emits a diagnostic and tears the engine down when load rejects', async () => {
@@ -497,7 +488,7 @@ describe('ShakaVideoSession', () => {
expect(player.selectTextTrackCalls).toHaveLength(2);
});
it('keeps external fallback available when the browser cannot run Shaka for clear DASH', async () => {
it('emits an unknown diagnostic when the browser cannot run Shaka for clear DASH', async () => {
const environment = createFakeShakaEnvironment();
environment.browserSupported = false;
const session = createSession(environment);
@@ -517,10 +508,9 @@ describe('ShakaVideoSession', () => {
stage: 'unknown',
failure: 'unknown',
});
expect(issues[0].externalFallbackRecommended).toBe(true);
});
it('keeps external fallback unavailable when browser support fails for KODIPROP DRM', async () => {
it('emits the same unknown diagnostic when browser support fails with DRM', async () => {
const environment = createFakeShakaEnvironment();
environment.browserSupported = false;
const session = createSession(environment);
@@ -536,7 +526,6 @@ describe('ShakaVideoSession', () => {
expect(issues[0].code).toBe(
PlaybackDiagnosticCode.UnknownPlaybackError
);
expect(issues[0].externalFallbackRecommended).toBe(false);
});
it('destroy tears down the engine and blocks later starts', async () => {
@@ -139,18 +139,13 @@ export class ShakaVideoSession {
}
if (!module.Player.isBrowserSupported()) {
this.emitTerminalIfCurrent(
generation,
null,
url,
drm === undefined
);
this.emitTerminalIfCurrent(generation, null, url);
return;
}
const player = new module.Player();
this.player = player;
this.bindPlayerListeners(player, generation, url, drm !== undefined);
this.bindPlayerListeners(player, generation, url);
if (drm?.clearKeys) {
player.configure({ drm: { clearKeys: drm.clearKeys } });
@@ -160,13 +155,7 @@ export class ShakaVideoSession {
await player.attach(video);
await player.load(url);
} catch (error: unknown) {
this.handleLoadFailure(
generation,
player,
url,
error,
drm !== undefined
);
this.handleLoadFailure(generation, player, url, error);
return;
}
@@ -202,8 +191,7 @@ export class ShakaVideoSession {
generation: number,
player: ShakaPlayerLike,
url: string,
error: unknown,
drmProvided: boolean
error: unknown
): void {
const shakaError = asShakaError(error);
if (
@@ -220,36 +208,17 @@ export class ShakaVideoSession {
ShakaDisposition.Terminal
);
if (issue) {
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(issue, drmProvided)
);
this.config.emitPlaybackIssue(issue);
}
// Never leave a non-functional engine attached to the media element
// or exposed to the shared-controls bridge.
this.beginPlayerTeardown();
}
/**
* Channels carrying KODIPROP ClearKey config cannot be handed to MPV/VLC
* at all — external players never receive the license config, so the
* encrypted stream fails there regardless of what broke inline (DRM,
* manifest, codec, network, …). Suppress the fallback hint entirely.
*/
private withoutUnusableDrmFallback(
issue: PlaybackDiagnostic,
drmProvided: boolean
): PlaybackDiagnostic {
if (!drmProvided || !issue.externalFallbackRecommended) {
return issue;
}
return { ...issue, externalFallbackRecommended: false };
}
private bindPlayerListeners(
player: ShakaPlayerLike,
generation: number,
url: string,
drmProvided: boolean
url: string
): void {
const errorListener = (event: Event): void => {
if (this.isStale(generation) || this.player !== player) {
@@ -272,9 +241,7 @@ export class ShakaVideoSession {
return;
}
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(issue, drmProvided)
);
this.config.emitPlaybackIssue(issue);
// Critical errors end playback; never leave the dead engine
// attached or exposed to the shared-controls bridge.
this.beginPlayerTeardown();
@@ -366,8 +333,7 @@ export class ShakaVideoSession {
private emitTerminalIfCurrent(
generation: number,
error: Partial<ShakaErrorLike> | null,
url: string,
externalFallbackRecommended?: boolean
url: string
): void {
const issue = classifyShakaPlaybackIssue(
error,
@@ -375,12 +341,7 @@ export class ShakaVideoSession {
ShakaDisposition.Terminal
);
if (issue) {
this.emitIfCurrent(
generation,
externalFallbackRecommended === undefined
? issue
: { ...issue, externalFallbackRecommended }
);
this.emitIfCurrent(generation, issue);
}
}
@@ -143,7 +143,6 @@ describe('VjsMpegTsSession', () => {
failure: 'http',
httpStatus: 503,
}),
externalFallbackRecommended: false,
})
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
@@ -167,7 +166,6 @@ describe('VjsMpegTsSession', () => {
expect.objectContaining({
code: 'network-error',
mpegTs: expect.objectContaining({ failure: 'network' }),
externalFallbackRecommended: false,
})
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
@@ -196,7 +196,6 @@ describe('VjsPlayerComponent', () => {
code: 'unsupported-container',
source: 'native',
sourceUrl: 'https://example.test/archive/movie.mkv',
externalFallbackRecommended: true,
})
);
});
@@ -236,7 +235,6 @@ describe('VjsPlayerComponent', () => {
stage: 'unknown',
httpStatus: 404,
},
externalFallbackRecommended: false,
})
);
});
@@ -272,7 +270,6 @@ describe('VjsPlayerComponent', () => {
disposition: 'terminal',
stage: 'unknown',
},
externalFallbackRecommended: false,
})
);
});
@@ -300,7 +297,6 @@ describe('VjsPlayerComponent', () => {
code: 'media-decode-error',
source: 'native',
nativeErrorMessage: 'Native media decode failed',
externalFallbackRecommended: true,
})
);
});
@@ -170,6 +170,14 @@ describe('PlaybackRecoverySession', () => {
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);
@@ -62,6 +62,11 @@ export class PlaybackRecoverySession {
this.invalidatePlaybackBinding();
}
endPlayback(): void {
this.switchPendingState.set(false);
this.invalidatePlaybackBinding();
}
recordFailure(binding: PlaybackBinding): boolean {
if (!this.accepts(binding)) {
return false;
@@ -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,52 @@
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 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 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 token = computed<WebPlayerApplicationToken>(() => {
if (sources.playback() === null) {
void sources.streamUrl();
void sources.startTime();
}
void isLive();
void sources.selectedPlayer();
void sources.reloadToken();
return Symbol();
});
return { playback, isLive, 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,75 @@
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 playback: ResolvedPortalPlayback;
readonly isLive: boolean;
readonly alternativeSourceCount: number;
}): readonly PlaybackRecommendation[] {
if (!options.diagnostic || !options.binding) {
return [];
}
const sourceKind = resolvePlaybackSourceKind(options.diagnostic);
const drm = options.playback.drm;
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: drm ? 'untransferable' : 'none',
externalTransferable: !drm,
},
alternativeSourceCount: options.alternativeSourceCount,
});
}
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 '@iptvnator/playback/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,95 @@
@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 (binding of renderedBindings(); track binding.generation) {
@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)"
(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()"
/>
} @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)"
(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() === '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()"
/>
} @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)"
(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() === 'embedded-mpv') {
}
@if (selectedPlayer() === 'embedded-mpv') {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(timeUpdate)="handleTimeUpdate($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @else {
@defer (on immediate) {
<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()"
/>
} @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()"
[alternativeSources]="alternativeSources()"
[pending]="recoveryPending()"
(retryRequested)="retryPlayback()"
(playerRequested)="requestRecommendedPlayer($event)"
(alternativeSourceRequested)="alternativeSourceRequested.emit($event)"
(sourceCheckRequested)="sourceCheckRequested.emit($event)"
/>
}
@@ -22,462 +22,3 @@ app-html-video-player {
height: 100%;
background: #000;
}
.web-player-diagnostic {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px);
overflow: auto;
background:
radial-gradient(
circle at 18% 18%,
rgb(255 176 76 / 6%),
transparent 28%
),
rgb(0 0 0 / 96%);
color: #f6f6f6;
}
.web-player-diagnostic__content {
width: min(100%, 720px);
}
.web-player-diagnostic__badge {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 8px;
padding: 5px 11px;
border: 1px solid rgb(255 174 66 / 42%);
border-radius: 6px;
background: rgb(255 148 35 / 15%);
color: #ffb24c;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.1;
text-transform: uppercase;
}
.web-player-diagnostic__badge mat-icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
font-size: 16px;
}
.web-player-diagnostic__badge span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__headline {
max-width: 660px;
margin: 20px 0 0;
color: #fff;
font-size: 1.65rem;
font-weight: 760;
letter-spacing: 0;
line-height: 1.05;
}
.web-player-diagnostic__description {
max-width: 760px;
margin-top: 12px;
color: rgb(255 255 255 / 73%);
font-size: 1rem;
line-height: 1.48;
}
.web-player-diagnostic__meta {
margin-top: 9px;
color: rgb(255 255 255 / 48%);
font-family: var(--app-monospace-font, monospace);
font-size: 0.82rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.web-player-diagnostic__codec-hint {
display: inline-block;
max-width: 100%;
margin-top: 12px;
padding: 4px 8px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 6px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
font-weight: 620;
line-height: 1.3;
overflow-wrap: anywhere;
}
// "Try another source" — the alternatives block that turns the error screen
// into a recovery point. The overlay is always dark, so these are literals
// rather than theme tokens, matching the rest of this diagnostic surface.
.web-player-diagnostic__alternatives {
width: min(420px, 100%);
margin: 20px auto 0;
padding: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
background: rgb(22 27 36 / 90%);
text-align: left;
// Scrolls itself rather than growing the whole overlay: otherwise a long
// list pushes the error headline and message off the top of the screen,
// which is precisely the information the user needs first.
max-height: min(46vh, 320px);
overflow-y: auto;
}
.web-player-diagnostic__alternatives-more {
margin: 0;
padding: 6px 12px 8px;
color: rgb(255 255 255 / 45%);
font-size: 0.75rem;
}
.web-player-diagnostic__alternatives-title {
margin: 0;
padding: 8px 12px 4px;
color: rgb(255 255 255 / 55%);
font-size: 0.594rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.web-player-diagnostic__player-actions {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
gap: 12px;
margin-top: 28px;
}
.web-player-diagnostic__player-card {
display: grid;
min-height: 60px;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 8px;
background: rgb(0 0 0 / 22%);
color: #f5f5f5;
cursor: pointer;
text-align: left;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.web-player-diagnostic__player-card:hover {
border-color: rgb(255 255 255 / 28%);
background: rgb(255 255 255 / 6%);
}
.web-player-diagnostic__player-card:active {
transform: translateY(1px);
}
.web-player-diagnostic__player-card:focus-visible,
.web-player-diagnostic__utility-button:focus-visible,
.web-player-diagnostic__details summary:focus-visible {
outline: 2px solid #ffb24c;
outline-offset: 3px;
}
.web-player-diagnostic__player-card--primary {
border-color: transparent;
background: #fff;
color: #151515;
}
.web-player-diagnostic__player-card--primary:hover {
border-color: transparent;
background: rgb(255 255 255 / 88%);
}
.web-player-diagnostic__player-card mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.web-player-diagnostic__player-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.web-player-diagnostic__player-label {
overflow: hidden;
font-size: 0.95rem;
font-weight: 760;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__player-hint {
overflow: hidden;
color: currentColor;
font-size: 0.78rem;
line-height: 1.1;
opacity: 0.62;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__utilities {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px 12px;
margin-top: 22px;
color: rgb(255 255 255 / 45%);
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.86rem;
font-weight: 640;
line-height: 1.2;
}
.web-player-diagnostic__utility-button:hover,
.web-player-diagnostic__details summary:hover {
color: rgb(255 255 255 / 76%);
}
.web-player-diagnostic__utility-button mat-icon,
.web-player-diagnostic__details summary mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.web-player-diagnostic__divider {
width: 1px;
height: 18px;
margin-top: 3px;
background: rgb(255 255 255 / 16%);
}
.web-player-diagnostic__details {
min-width: 0;
max-width: 100%;
}
.web-player-diagnostic__details summary {
list-style: none;
}
.web-player-diagnostic__details summary::-webkit-details-marker {
display: none;
}
.web-player-diagnostic__details[open] {
flex-basis: 100%;
}
.web-player-diagnostic__details dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 7px 14px;
max-width: min(100%, 640px);
margin: 12px 0 0;
padding: 12px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 8px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
line-height: 1.3;
}
.web-player-diagnostic__detail-row {
display: contents;
}
.web-player-diagnostic__detail-row dt {
color: rgb(255 255 255 / 48%);
font-weight: 700;
white-space: nowrap;
}
.web-player-diagnostic__detail-row dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
@container (max-width: 760px) {
.web-player-diagnostic {
align-items: flex-start;
padding: 22px 16px;
}
.web-player-diagnostic__badge {
white-space: normal;
}
.web-player-diagnostic__badge span {
white-space: normal;
}
.web-player-diagnostic__headline {
margin-top: 16px;
font-size: 1.48rem;
}
.web-player-diagnostic__utilities {
gap: 10px 12px;
}
.web-player-diagnostic__details dl {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 520px) {
.web-player-diagnostic {
align-items: flex-start;
padding: clamp(14px, 5cqi, 22px);
}
.web-player-diagnostic__content {
width: 100%;
}
.web-player-diagnostic__badge {
gap: 6px;
padding: 4px 8px;
font-size: 0.7rem;
white-space: normal;
}
.web-player-diagnostic__badge span {
white-space: nowrap;
}
.web-player-diagnostic__headline {
margin-top: 14px;
font-size: 1.35rem;
line-height: 1.08;
}
.web-player-diagnostic__description {
margin-top: 10px;
font-size: 0.9rem;
line-height: 1.38;
}
.web-player-diagnostic__meta {
margin-top: 8px;
font-size: 0.74rem;
}
.web-player-diagnostic__codec-hint {
margin-top: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__player-actions {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-top: 16px;
}
.web-player-diagnostic__player-card {
min-height: 52px;
padding: 10px 12px;
}
.web-player-diagnostic__utilities {
display: grid;
gap: 7px;
margin-top: 16px;
}
.web-player-diagnostic__divider {
display: none;
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
width: fit-content;
min-height: 22px;
font-size: 0.82rem;
}
.web-player-diagnostic__details[open] {
flex-basis: auto;
}
.web-player-diagnostic__details dl {
max-height: 260px;
overflow: auto;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
margin-top: 8px;
padding: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 340px) {
.web-player-diagnostic {
padding: 12px;
}
.web-player-diagnostic__headline {
font-size: 1.22rem;
}
.web-player-diagnostic__description {
font-size: 0.84rem;
}
.web-player-diagnostic__player-label {
font-size: 0.88rem;
}
}
@@ -30,6 +30,7 @@ import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} 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,
@@ -199,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);
});
@@ -211,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);
@@ -221,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([
@@ -271,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 })
);
}
);
@@ -338,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() {
@@ -358,6 +379,5 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
player: 'html5',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: false,
};
}
@@ -22,6 +22,14 @@ import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} 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,
@@ -237,7 +246,7 @@ describe('WebPlayerViewComponent', () => {
]);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
fixture.detectChanges();
const check = fixture.debugElement.query(
@@ -259,7 +268,7 @@ describe('WebPlayerViewComponent', () => {
);
fixture.detectChanges();
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -296,7 +305,7 @@ describe('WebPlayerViewComponent', () => {
const issue = createHttpDiagnostic();
fixture.detectChanges();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const banner = fixture.debugElement.query(
@@ -308,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',
@@ -322,10 +331,10 @@ describe('WebPlayerViewComponent', () => {
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(
@@ -346,10 +355,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(
@@ -375,10 +384,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(
@@ -405,13 +414,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([
{
@@ -433,59 +442,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', {
@@ -629,11 +585,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)
@@ -798,10 +754,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'
);
});
@@ -810,14 +766,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(
@@ -840,7 +796,7 @@ describe('WebPlayerViewComponent', () => {
fixture.detectChanges();
const issue = createUnsupportedCodecDiagnostic();
component.handlePlaybackIssue(issue);
emitPlaybackIssue(issue);
fixture.detectChanges();
const details = fixture.debugElement.query(
@@ -853,8 +809,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',
@@ -890,7 +846,7 @@ describe('WebPlayerViewComponent', () => {
expect.objectContaining({ reloadToken: 0 })
);
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
emitPlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
const retryButton = fixture.debugElement.query(
@@ -904,9 +860,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(
@@ -1031,6 +990,23 @@ 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
);
}
});
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
@@ -1043,7 +1019,6 @@ function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: true,
};
}
@@ -1058,7 +1033,6 @@ function createBrowserAccessDiagnostic(): PlaybackDiagnostic {
audioCodecs: [],
videoCodecs: [],
details: 'blocked by CORS policy',
externalFallbackRecommended: true,
};
}
@@ -1073,7 +1047,6 @@ function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic {
audioCodecs: ['ac-3'],
videoCodecs: ['hvc1.1.6.L93.B0'],
details: 'manifestIncompatibleCodecsError',
externalFallbackRecommended: true,
};
}
@@ -1088,7 +1061,6 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
audioCodecs: [],
videoCodecs: [],
details: 'HttpStatusCodeInvalid {"code":456,"msg":"<none>"}',
externalFallbackRecommended: false,
};
}
@@ -1106,7 +1078,6 @@ function createHttpDiagnostic(): PlaybackDiagnostic {
nativeErrorMessage: 'source not supported',
httpStatus: 404,
nativeErrorType: 'networkrequestfailed',
externalFallbackRecommended: false,
};
}
@@ -1130,7 +1101,6 @@ function createStructuredHlsDiagnostic(): PlaybackDiagnostic {
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}
@@ -1156,7 +1126,6 @@ function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
stage: 'unknown',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}
@@ -1184,7 +1153,6 @@ function createStructuredShakaDiagnostic(): PlaybackDiagnostic {
failure: 'network',
httpStatus: 503,
},
externalFallbackRecommended: false,
};
}
@@ -1211,6 +1179,5 @@ function createStructuredMpegTsDiagnostic(): PlaybackDiagnostic {
failure: 'http',
httpStatus: 404,
},
externalFallbackRecommended: false,
};
}
@@ -12,50 +12,47 @@ 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 '@iptvnator/playback/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 } from './web-player-application-state';
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
import {
createWebPlayerRecommendations,
toInlinePlaybackPlayer,
toVideoPlayer,
} from './web-player-recovery-policy';
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
@@ -68,20 +65,13 @@ function resolveWebPlayerSharedControls(): boolean {
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,82 +82,78 @@ 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
);
streamUrl = input.required<string>();
readonly streamUrl = input.required<string>();
readonly playbackSessionKey = 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 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[]>([]);
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 playbackApplicationToken = this.applicationState.token;
readonly effectiveStartTime = computed(() =>
this.recoverySession.resumeStartTime(
this.startTime(),
this.resolvedIsLive()
)
);
readonly visiblePlaybackDiagnostic = computed(() =>
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
? null
@@ -176,229 +162,147 @@ export class WebPlayerViewComponent implements OnDestroy {
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 resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
const playback = this.playback();
if (playback) {
return playback;
}
return {
streamUrl: this.streamUrl(),
title: this.title() || this.streamUrl(),
startTime: this.startTime(),
};
});
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);
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,
playback: this.resolvedPlayback(),
isLive: this.resolvedIsLive(),
alternativeSourceCount: this.alternativeSources().length,
});
});
readonly renderedBindings = computed<readonly PlaybackBinding[]>(() => {
const binding = this.activeBinding();
return binding ? [binding] : [];
});
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);
this.syncRecoverySession();
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;
if (target === null) {
this.applicationHandoff.release();
this.recoverySession.clearPlaybackBinding();
this.playbackDiagnostic.set(null);
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;
this.playbackDiagnostic.set(null);
}
);
});
}
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) {
if (
!this.applicationHandoff.owns(
binding,
this.playbackApplicationToken()
)
) {
this.applicationHandoff.invalidate();
this.recoverySession.clearPlaybackBinding();
this.playbackDiagnostic.set(null);
return;
}
this.playbackDiagnostic.set(issue);
if (issue) {
this.playbackFailed.emit(issue.code);
if (!issue) {
this.recoverySession.settle(binding);
this.playbackDiagnostic.set(null);
return;
}
if (!this.recoverySession.recordFailure(binding)) {
return;
}
this.playbackDiagnostic.set(issue);
this.playbackFailed.emit(issue.code);
}
requestExternalFallback(player: ExternalPlayerName): void {
handleTimeUpdate(event: { currentTime: number; duration: number }): void {
this.recoverySession.recordTimeUpdate(event, this.resolvedIsLive());
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()
);
return matchingKey ? headers[matchingKey] : undefined;
private syncRecoverySession(): void {
const sessionKey = this.playbackSessionKey();
if (untracked(() => this.recoverySession.syncSession(sessionKey))) {
this.playbackDiagnostic.set(null);
}
}
}
@@ -7,7 +7,10 @@ import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
* 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);