From 051afe5c217b939dc591163e0f2cbd41a307b394 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 4 Aug 2026 13:57:20 +0200 Subject: [PATCH] feat(playback): switch temporarily to recommended players --- .../mpegts-playback-evidence.util.spec.ts | 128 +- .../diagnostics/playback-diagnostics.model.ts | 1 - .../playback-diagnostics.util.spec.ts | 22 - .../diagnostics/playback-diagnostics.util.ts | 16 - .../shaka-error-classifier.spec.ts | 3 - .../lib/diagnostics/shaka-error-classifier.ts | 3 - .../playback-recommendation-policy.spec.ts | 1 - .../lib/playback-target-capabilities.spec.ts | 1 - .../video-player.component.spec.ts | 1 - .../unified-live-tab.component.spec.ts | 1 - ...alker-live-stream-layout.component.spec.ts | 1 - .../art-player/art-player.component.spec.ts | 3 - ....component.shared-controls.sources.spec.ts | 1 - .../html-video-player.component.spec.ts | 1 - ...layback-diagnostic-panel.component.spec.ts | 1 - .../playback-diagnostic-view.util.spec.ts | 1 - .../portal-inline-player.component.spec.ts | 1 - .../shaka-engine/shaka-video-session.spec.ts | 17 +- .../lib/shaka-engine/shaka-video-session.ts | 57 +- .../lib/vjs-player/vjs-mpegts-session.spec.ts | 2 - .../vjs-player/vjs-player.component.spec.ts | 4 - .../playback-recovery-session.spec.ts | 8 + .../playback-recovery-session.ts | 5 + .../web-player-application-handoff.ts | 116 ++ .../web-player-application-state.ts | 52 + .../web-player-playback-state.spec.ts | 175 +++ .../web-player-playback-state.ts | 119 ++ .../web-player-recovery-policy.ts | 75 ++ .../web-player-view-diagnostics.utils.ts | 217 ---- .../web-player-view.component.html | 339 ++---- ...web-player-view.component.recovery.spec.ts | 1038 +++++++++++++++++ .../web-player-view.component.scss | 459 -------- ...yer-view.component.shared-controls.spec.ts | 60 +- .../web-player-view.component.spec.ts | 143 +-- .../web-player-view.component.ts | 470 +++----- .../web-player-view.spec-stubs.ts | 20 +- 36 files changed, 2019 insertions(+), 1543 deletions(-) create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-application-handoff.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts delete mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts diff --git a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts index 583f4cd34..4de6b5da2 100644 --- a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts @@ -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(); diff --git a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts index 31c3a94df..d244c323b 100644 --- a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts @@ -264,7 +264,6 @@ export interface PlaybackDiagnostic { readonly hls?: HlsPlaybackEvidence; readonly mpegTs?: MpegTsPlaybackEvidence; readonly shaka?: ShakaPlaybackEvidence; - readonly externalFallbackRecommended: boolean; } export interface PlaybackFallbackRequest { diff --git a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts index 9f94ed6d8..060ee97e9 100644 --- a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts @@ -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', () => { diff --git a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts index d2645d930..3dbad20e6 100644 --- a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts @@ -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; } diff --git a/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts index 28effb252..394b48144 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts @@ -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); }); }); diff --git a/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts index 4bef97f68..c44383bab 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts @@ -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, }); } diff --git a/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts b/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts index 450696f9d..adccb11a3 100644 --- a/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts +++ b/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts @@ -37,7 +37,6 @@ function diagnostic(code: PlaybackDiagnosticCode): PlaybackDiagnostic { container: '', audioCodecs: [], videoCodecs: [], - externalFallbackRecommended: false, }; } diff --git a/libs/playback/util/src/lib/playback-target-capabilities.spec.ts b/libs/playback/util/src/lib/playback-target-capabilities.spec.ts index c7aae1445..b91379383 100644 --- a/libs/playback/util/src/lib/playback-target-capabilities.spec.ts +++ b/libs/playback/util/src/lib/playback-target-capabilities.spec.ts @@ -24,7 +24,6 @@ function diagnostic( container: '', audioCodecs: [], videoCodecs: [], - externalFallbackRecommended: false, ...overrides, }; } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 78a989f4f..7bd2ab596 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -590,7 +590,6 @@ describe('VideoPlayerComponent', () => { container: 'm3u8', audioCodecs: ['ac-3'], videoCodecs: ['avc1.64001f'], - externalFallbackRecommended: true, }, } satisfies PlaybackFallbackRequest); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index ef25a8b87..0eb895fb0 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -619,7 +619,6 @@ describe('UnifiedLiveTabComponent', () => { mimeType: '', videoCodecs: [], audioCodecs: [], - externalFallbackRecommended: false, }, }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 1fb5e3d7a..3b0034850 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -556,7 +556,6 @@ describe('StalkerLiveStreamLayoutComponent', () => { mimeType: '', videoCodecs: [], audioCodecs: [], - externalFallbackRecommended: true, }, }; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index 60f0f0bcb..166deb8b6 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -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); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts index ac5f748d5..a0ad2352f 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts @@ -154,7 +154,6 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => { failure: 'http', httpStatus: 404, }), - externalFallbackRecommended: false, }), ]); expect(JSON.stringify(issues)).not.toContain(secret); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index 2eb43b0c6..db0fda8fd 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -203,7 +203,6 @@ describe('HtmlVideoPlayerComponent', () => { code: 'unsupported-container', source: 'native', sourceUrl: 'http://test.ts', - externalFallbackRecommended: true, }), ]); }); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts index bd97b0e5c..fa5243259 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts @@ -26,7 +26,6 @@ const DIAGNOSTIC: PlaybackDiagnostic = { player: 'videojs', audioCodecs: [], videoCodecs: [], - externalFallbackRecommended: true, }; const PLAYBACK: ResolvedPortalPlayback = { diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts index 275669c9d..94e9c778a 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts @@ -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', () => { diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index c8a208901..b547f6392 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -160,7 +160,6 @@ describe('PortalInlinePlayerComponent', () => { mimeType: '', videoCodecs: [], audioCodecs: [], - externalFallbackRecommended: true, }, }; let forwarded: PlaybackFallbackRequest | undefined; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts index b4d6aa59e..8be1d30c1 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts @@ -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 () => { diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts index 0fd1f39a8..824ced37d 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -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 | 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); } } diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts index e8603a3ad..f9194cd40 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts @@ -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( diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index f24552bc5..bb2d7ce58 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -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, }) ); }); diff --git a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts index b06d0eade..1afca3e57 100644 --- a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts @@ -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); diff --git a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts index dabab40b9..1fb0099ac 100644 --- a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts +++ b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts @@ -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; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-application-handoff.ts b/libs/ui/playback/src/lib/web-player-view/web-player-application-handoff.ts new file mode 100644 index 000000000..4139ec020 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-application-handoff.ts @@ -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; + 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(); + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts b/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts new file mode 100644 index 000000000..e470aac14 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts @@ -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; + readonly streamUrl: Signal; + readonly title: Signal; + readonly startTime: Signal; + readonly selectedPlayer: Signal; + readonly reloadToken: Signal; +}): { + readonly playback: Signal; + readonly isLive: Signal; + readonly token: Signal; +} { + 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(() => { + if (sources.playback() === null) { + void sources.streamUrl(); + void sources.startTime(); + } + void isLive(); + void sources.selectedPlayer(); + void sources.reloadToken(); + return Symbol(); + }); + return { playback, isLive, token }; +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts new file mode 100644 index 000000000..0864280d4 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts @@ -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 }], + }); + }); +}); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts b/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts new file mode 100644 index 000000000..16308fc5b --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts new file mode 100644 index 000000000..3e5fceff7 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts @@ -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; + 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; + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts deleted file mode 100644 index 966c437b8..000000000 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts +++ /dev/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; - } -} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 5865fee59..56c40cc96 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -1,278 +1,95 @@ -@if (selectedPlayer() === 'videojs') { - @defer (on immediate) { - - @if (vjsOptions) { - +@for (binding of renderedBindings(); track binding.generation) { + @if (binding.target === 'videojs') { + @defer (on immediate) { + @if (vjsOptions) { + + } + } @placeholder { + } - } @placeholder { - - } -} @else if (selectedPlayer() === 'html5') { - @defer (on immediate) { - @if (channel) { - + } @else if (binding.target === 'html5') { + @defer (on immediate) { + @if (channel) { + + } + } @placeholder { + } - } @placeholder { - - } -} @else if (selectedPlayer() === 'artplayer') { - @defer (on immediate) { - @if (channel) { - + } @else if (binding.target === 'artplayer') { + @defer (on immediate) { + @if (channel) { + + } + } @placeholder { + } - } @placeholder { - } -} @else if (selectedPlayer() === 'embedded-mpv') { +} + +@if (selectedPlayer() === 'embedded-mpv') { -} @else { - @defer (on immediate) { - - } @placeholder { - - } } @if (visiblePlaybackDiagnostic(); as issue) { -
-
-
- - {{ getDiagnosticTitleKey(issue) | translate }} -
- -

- {{ diagnosticHeadlineKey() | translate }} -

- -
- {{ getDiagnosticDescriptionKey(issue) | translate }} -
- - @if (getDiagnosticMeta(issue); as meta) { -
- {{ meta }} -
- } - - @if (getDiagnosticCodecHint(issue); as codecHint) { -
- {{ - 'PLAYBACK_DIAGNOSTICS.CODEC_HINT' - | translate: { codecs: codecHint } - }} -
- } - - @if (alternativeSources().length > 0) { -
-

- {{ - 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE' - | translate - }} -

- @for (source of visibleAlternatives(); track source.id) { - - } - @if (hiddenAlternativeCount() > 0) { -

- {{ - 'PORTALS.MULTI_SOURCE.MORE_SOURCES' - | translate - : { count: hiddenAlternativeCount() } - }} -

- } -
- } - - @if (canShowExternalFallbackActions()) { -
- - - -
- } - -
- - - - - - - - -
- - - {{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }} - -
- @for ( - detail of getDiagnosticDetails(issue); - track detail.labelKey - ) { -
-
{{ detail.labelKey | translate }}
-
{{ detail.value }}
-
- } -
-
-
-
-
+ } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts new file mode 100644 index 000000000..f27bc4f7a --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts @@ -0,0 +1,1038 @@ +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { + ComponentFixture, + DeferBlockBehavior, + TestBed, +} from '@angular/core/testing'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { By } from '@angular/platform-browser'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { TranslateModule } from '@ngx-translate/core'; +import { + InlinePlaybackPlayer, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from '@iptvnator/playback/util'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + STORE_KEY, + VideoPlayer, + type ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { VodSourceRowComponent } from '@iptvnator/ui/components'; +import { of } from 'rxjs'; +import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; +import { + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, +} from './web-player-view.spec-stubs'; +import { ElectronStreamHeadersService } from './electron-stream-headers.service'; +import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; + +jest.unstable_mockModule('video.js', () => ({ default: jest.fn() })); +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); + +describe('WebPlayerViewComponent recovery integration', () => { + let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; + let fixture: ComponentFixture; + let component: WebPlayerViewComponentInstance; + const storedSettings = { player: VideoPlayer.VideoJs }; + const storage = { + get: jest.fn((key: string) => + of(key === STORE_KEY.Settings ? storedSettings : undefined) + ), + }; + let runtime: { supportsManagedExternalPlayers: boolean }; + let holdHeaderHandoff: boolean; + let headerResolvers: Array<(stillCurrent: boolean) => void>; + let headerRejectors: Array<(reason?: unknown) => void>; + const streamHeaders = { + apply: jest.fn( + () => + (holdHeaderHandoff + ? new Promise((resolve, reject) => { + headerResolvers.push(resolve); + headerRejectors.push(reject); + }) + : null) as Promise | null + ), + clear: jest.fn(), + }; + + beforeAll(async () => { + ({ WebPlayerViewComponent } = + await import('./web-player-view.component')); + }); + + beforeEach(async () => { + runtime = { supportsManagedExternalPlayers: true }; + holdHeaderHandoff = false; + headerResolvers = []; + headerRejectors = []; + streamHeaders.apply.mockClear(); + streamHeaders.clear.mockClear(); + await TestBed.configureTestingModule({ + deferBlockBehavior: DeferBlockBehavior.Playthrough, + imports: [WebPlayerViewComponent, TranslateModule.forRoot()], + providers: [ + { provide: StorageMap, useValue: storage }, + { provide: RuntimeCapabilitiesService, useValue: runtime }, + { + provide: ElectronStreamHeadersService, + useValue: streamHeaders, + }, + { + provide: SettingsStore, + useValue: { + showCaptions: () => false, + webPlayerSharedControls: () => false, + }, + }, + ], + }) + .overrideComponent(WebPlayerViewComponent, { + set: { + imports: [ + ClipboardModule, + MatButtonModule, + MatIconModule, + MatTooltipModule, + PlaybackDiagnosticPanelComponent, + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, + TranslateModule, + VodSourceRowComponent, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(WebPlayerViewComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput( + 'streamUrl', + 'https://example.com/live.m3u8' + ); + fixture.componentRef.setInput('title', 'Recovery stream'); + fixture.componentRef.setInput('playbackSessionKey', 'content-a'); + }); + + afterEach(() => fixture.destroy()); + + it('ranks HTML5, MPV, then VLC for a fatal Video.js HLS media failure', async () => { + await render(); + + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual([ + 'playback-recommendation-html5', + 'playback-fallback-mpv', + 'playback-fallback-vlc', + ]); + }); + + it('mounts HTML5 temporarily without changing the saved Video.js setting', async () => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect( + fixture.debugElement.query( + By.directive(StubHtmlVideoPlayerComponent) + ) + ).not.toBeNull(); + expect( + fixture.debugElement.query(By.directive(StubVjsPlayerComponent)) + ).toBeNull(); + expect(storedSettings.player).toBe(VideoPlayer.VideoJs); + }); + + it('excludes Video.js and HTML5 player actions after the switched target fails', async () => { + await switchToHtml5(); + + html5().playbackIssue.emit(mediaIssue('html5')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual([ + 'playback-fallback-mpv', + 'playback-fallback-vlc', + ]); + }); + + it('retries the active target with a reload while preserving attempts', async () => { + const fallbackRequests: unknown[] = []; + component.externalFallbackRequested.subscribe((request) => + fallbackRequests.push(request) + ); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + click('playback-fallback-mpv'); + expect(fallbackRequests).toHaveLength(1); + vjs().playbackIssue.emit(networkIssue('videojs')); + fixture.detectChanges(); + + click('playback-retry'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(vjs().options()).toEqual( + expect.objectContaining({ reloadToken: 1 }) + ); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + expect(playerActionIds()).toEqual([ + 'playback-recommendation-html5', + 'playback-fallback-vlc', + ]); + }); + + it('preserves attempts when the URL changes under the same content key', async () => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + click('playback-fallback-mpv'); + + setPlayback({ streamUrl: 'https://example.com/alternate.m3u8' }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + vjs().playbackIssue.emit(mediaIssue('videojs', 'alternate.m3u8')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual([ + 'playback-recommendation-html5', + 'playback-fallback-vlc', + ]); + }); + + it('clears attempts and the temporary override for a new content key', async () => { + await switchToHtml5(); + + fixture.componentRef.setInput('playbackSessionKey', 'content-b'); + setPlayback({ streamUrl: 'https://example.com/next.m3u8' }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect( + fixture.debugElement.query(By.directive(StubVjsPlayerComponent)) + ).not.toBeNull(); + vjs().playbackIssue.emit(mediaIssue('videojs', 'next.m3u8')); + fixture.detectChanges(); + expect(playerActionIds()).toEqual([ + 'playback-recommendation-html5', + 'playback-fallback-mpv', + 'playback-fallback-vlc', + ]); + }); + + it('hands the latest finite VOD time to a switch and starts live at zero', async () => { + setPlayback({ + streamUrl: 'https://example.com/movie.m3u8', + isLive: false, + startTime: 7, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 9, + contentType: 'vod', + }, + }); + await render(); + vjs().timeUpdate.emit({ currentTime: 48, duration: 120 }); + vjs().timeUpdate.emit({ currentTime: Number.NaN, duration: 120 }); + vjs().playbackIssue.emit(mediaIssue('videojs', 'movie.m3u8')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(html5().startTime()).toBe(48); + + fixture.componentRef.setInput('playbackSessionKey', 'content-live'); + setPlayback({ + streamUrl: 'https://example.com/live-2.m3u8', + isLive: true, + startTime: 99, + }); + fixture.detectChanges(); + vjs().timeUpdate.emit({ currentTime: 74, duration: 100 }); + vjs().playbackIssue.emit(mediaIssue('videojs', 'live-2.m3u8')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(html5().startTime()).toBe(0); + }); + + it('ignores actual outputs from the destroyed target generation after switching players', async () => { + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + await render(); + const staleVjs = vjs(); + staleVjs.playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { + // Expected when probing an output owned by a destroyed stub. + }); + staleVjs.playbackIssue.emit(mediaIssue('videojs', 'stale.m3u8')); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + + html5().playbackIssue.emit(mediaIssue('html5')); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.player).toBe('html5'); + + staleVjs.playbackIssue.emit(null); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.player).toBe('html5'); + expect(failures).toEqual([ + PlaybackDiagnosticCode.MediaDecodeError, + PlaybackDiagnosticCode.MediaDecodeError, + ]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Unexpected emit for destroyed') + ); + warn.mockRestore(); + }); + + it('recreates a same-target generation and ignores old diagnostic and clear outputs', async () => { + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + await render(); + const staleVjs = vjs(); + staleVjs.playbackIssue.emit(networkIssue('videojs')); + fixture.detectChanges(); + + click('playback-retry'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const currentVjs = vjs(); + expect(currentVjs).not.toBe(staleVjs); + expect(query('playback-diagnostic-banner')).toBeNull(); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { + // Expected when probing an output owned by a destroyed stub. + }); + staleVjs.playbackIssue.emit(mediaIssue('videojs', 'stale.m3u8')); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + + currentVjs.playbackIssue.emit(mediaIssue('videojs', 'current.m3u8')); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.sourceUrl).toContain( + 'current.m3u8' + ); + + staleVjs.playbackIssue.emit(null); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.sourceUrl).toContain( + 'current.m3u8' + ); + expect(failures).toEqual([ + PlaybackDiagnosticCode.NetworkError, + PlaybackDiagnosticCode.MediaDecodeError, + ]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Unexpected emit for destroyed') + ); + warn.mockRestore(); + }); + + it('rejects old player outputs when playback changes before the application effect runs', async () => { + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + await render(); + component.retryPlayback(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const sourceAPlayer = vjs(); + expect(component.recoveryPending()).toBe(true); + + setPlayback({ streamUrl: 'https://example.com/source-b.m3u8' }); + sourceAPlayer.playbackIssue.emit( + mediaIssue('videojs', 'source-a.m3u8') + ); + sourceAPlayer.playbackIssue.emit(null); + + expect(component.playbackDiagnostic()).toBeNull(); + expect(component.recoveryPending()).toBe(true); + expect(component.activeBinding()).toBeNull(); + expect(failures).toEqual([]); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const sourceBPlayer = vjs(); + expect(sourceBPlayer).not.toBe(sourceAPlayer); + sourceBPlayer.playbackIssue.emit( + mediaIssue('videojs', 'source-b.m3u8') + ); + fixture.detectChanges(); + + expect(component.playbackDiagnostic()?.sourceUrl).toBe( + 'https://example.com/source-b.m3u8' + ); + expect(component.recoveryPending()).toBe(false); + expect(failures).toEqual([PlaybackDiagnosticCode.MediaDecodeError]); + }); + + it('keeps the active recovery binding exact and credential-free', async () => { + const sentinels = [ + 'credential-stream.example/private.m3u8', + 'sentinel-cookie=secret', + 'Bearer sentinel-authorization', + 'SentinelAgent/7.0', + 'https://sentinel-referer.example', + 'https://sentinel-origin.example', + 'sentinel-clear-key-id', + 'sentinel-clear-key-value', + 'Sentinel private title', + ]; + setPlayback({ + streamUrl: `https://${sentinels[0]}`, + title: sentinels[8], + headers: { + Cookie: sentinels[1], + Authorization: sentinels[2], + }, + userAgent: sentinels[3], + referer: sentinels[4], + origin: sentinels[5], + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { [sentinels[6]]: sentinels[7] }, + }, + }); + await render(); + + const binding = component.activeBinding(); + expect(binding).toEqual({ + generation: expect.any(Number), + target: InlinePlaybackPlayer.VideoJs, + }); + expect(Object.keys(binding ?? {})).toEqual(['generation', 'target']); + const inspected = JSON.stringify(binding); + for (const sentinel of sentinels) { + expect(inspected).not.toContain(sentinel); + } + }); + + it('exposes a fieldless opaque application token without source material', async () => { + const sentinels = [ + 'opaque-stream.example/private.m3u8', + 'OpaqueAgent/4.0', + 'opaque-cookie=secret', + 'opaque-clear-key-value', + ]; + setPlayback({ + streamUrl: `https://${sentinels[0]}`, + headers: { Cookie: sentinels[2] }, + userAgent: sentinels[1], + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { key: sentinels[3] }, + }, + }); + await render(); + + const token = ( + component as unknown as { + readonly playbackApplicationToken?: () => unknown; + } + ).playbackApplicationToken?.(); + expect(token).toBeDefined(); + expect(typeof token).toBe('symbol'); + expect(Reflect.ownKeys(Object(token))).toEqual([]); + const inspected = `${String(token)} ${JSON.stringify({ token })}`; + for (const sentinel of sentinels) { + expect(inspected).not.toContain(sentinel); + } + }); + + it('keeps fallback ownership across title and time updates', async () => { + await render(); + const binding = component.activeBinding(); + const player = vjs(); + + fixture.componentRef.setInput('title', 'Updated display title'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.activeBinding()).toBe(binding); + expect(vjs()).toBe(player); + + player.timeUpdate.emit({ currentTime: 31, duration: 90 }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.activeBinding()).toBe(binding); + expect(vjs()).toBe(player); + }); + + it('rebinds for source, headers, DRM, live mode, target, and reload changes', async () => { + await render(); + await expectBindingChange(() => + fixture.componentRef.setInput( + 'streamUrl', + 'https://example.com/relevant-url.m3u8' + ) + ); + await expectBindingChange(() => + fixture.componentRef.setInput('startTime', 14) + ); + + let playback: Partial = { + streamUrl: 'https://example.com/explicit.m3u8', + isLive: false, + headers: { Authorization: 'Bearer first' }, + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { first: 'key-one' }, + }, + }; + await expectBindingChange(() => setPlayback(playback)); + playback = { + ...playback, + headers: { Authorization: 'Bearer second' }, + }; + await expectBindingChange(() => setPlayback(playback)); + playback = { + ...playback, + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { second: 'key-two' }, + }, + }; + await expectBindingChange(() => setPlayback(playback)); + playback = { ...playback, isLive: true }; + await expectBindingChange(() => setPlayback(playback)); + await expectBindingChange(() => + fixture.componentRef.setInput( + 'playerOverride', + VideoPlayer.ArtPlayer + ) + ); + await expectBindingChange(() => component.retryPlayback()); + }); + + it('never ranks MPV or VLC for DRM playback', async () => { + setPlayback({ + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { + '00112233445566778899aabbccddeeff': + 'ffeeddccbbaa99887766554433221100', + }, + }, + }); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual(['playback-recommendation-html5']); + }); + + it('does not render managed external actions in the PWA runtime', async () => { + runtime.supportsManagedExternalPlayers = false; + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual(['playback-recommendation-html5']); + }); + + it('prevents a second rendered action while a player switch is pending', async () => { + const fallbackRequests: unknown[] = []; + component.externalFallbackRequested.subscribe((request) => + fallbackRequests.push(request) + ); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + holdHeaderHandoff = true; + const htmlButton = requiredButton('playback-recommendation-html5'); + + htmlButton.click(); + fixture.detectChanges(); + const mpvButton = requiredButton('playback-fallback-mpv'); + expect(query('playback-diagnostic-banner')).not.toBeNull(); + expect(mpvButton.disabled).toBe(true); + mpvButton.click(); + expect(fallbackRequests).toEqual([]); + + expect(headerResolvers).toHaveLength(1); + headerResolvers[0](true); + await fixture.whenStable(); + fixture.detectChanges(); + expect(html5().channel()).toEqual( + expect.objectContaining({ + url: 'https://example.com/live.m3u8', + }) + ); + expect(query('playback-diagnostic-banner')).toBeNull(); + }); + + it.each([ + { + outcome: 'false', + complete: () => headerResolvers[0](false), + }, + { + outcome: 'rejection', + complete: () => headerRejectors[0](new Error('header IPC failed')), + }, + ])( + 'settles the current handoff after a $outcome without applying the source', + async ({ complete }) => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + holdHeaderHandoff = true; + + click('playback-recommendation-html5'); + fixture.detectChanges(); + expect(component.recoveryPending()).toBe(true); + expect(requiredButton('playback-fallback-mpv').disabled).toBe(true); + + complete(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.recoveryPending()).toBe(false); + expect(requiredButton('playback-fallback-mpv').disabled).toBe( + false + ); + expect(component.playbackDiagnostic()).not.toBeNull(); + expect(component.channel).toBeUndefined(); + } + ); + + it.each([ + { + outcome: 'false', + completeStale: () => headerResolvers[0](false), + }, + { + outcome: 'rejection', + completeStale: () => + headerRejectors[0](new Error('stale header IPC failed')), + }, + ])( + 'does not settle the current handoff when a stale one completes with $outcome', + async ({ completeStale }) => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + holdHeaderHandoff = true; + click('playback-recommendation-html5'); + fixture.detectChanges(); + + setPlayback({ streamUrl: 'https://example.com/current.m3u8' }); + fixture.detectChanges(); + expect(headerResolvers).toHaveLength(2); + + completeStale(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.recoveryPending()).toBe(true); + expect(requiredButton('playback-fallback-mpv').disabled).toBe(true); + expect(component.playbackDiagnostic()).not.toBeNull(); + expect(component.channel).toBeUndefined(); + + headerResolvers[1](false); + await fixture.whenStable(); + } + ); + + it('rejects a pending header success for a newer pre-effect source intent', async () => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + const diagnostic = component.playbackDiagnostic(); + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + holdHeaderHandoff = true; + + setPlayback({ streamUrl: 'https://example.com/source-a.m3u8' }); + fixture.detectChanges(); + const sourceABinding = component.activeBinding(); + expect(sourceABinding).not.toBeNull(); + expect(headerResolvers).toHaveLength(1); + expect(component.channel).toBeUndefined(); + + setPlayback({ streamUrl: 'https://example.com/source-b.m3u8' }); + const pendingState = { + binding: component.activeBinding(), + pending: component.recoveryPending(), + diagnostic: component.playbackDiagnostic(), + channel: component.channel, + vjsOptions: component.vjsOptions, + failures: [...failures], + }; + + headerResolvers[0](true); + await Promise.resolve(); + + expect({ + binding: component.activeBinding(), + pending: component.recoveryPending(), + diagnostic: component.playbackDiagnostic(), + channel: component.channel, + vjsOptions: component.vjsOptions, + failures, + }).toEqual(pendingState); + expect(component.playbackDiagnostic()).toBe(diagnostic); + expect(streamHeaders.apply).toHaveBeenCalledTimes(2); + + fixture.detectChanges(); + expect(headerResolvers).toHaveLength(2); + expect(component.activeBinding()).not.toBe(sourceABinding); + headerResolvers[1](true); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.channel?.url).toBe( + 'https://example.com/source-b.m3u8' + ); + expect(component.vjsOptions?.sources).toEqual([ + expect.objectContaining({ + src: 'https://example.com/source-b.m3u8', + }), + ]); + expect(component.playbackDiagnostic()).toBeNull(); + expect(failures).toEqual([]); + }); + + it.each([ + { + outcome: 'success', + complete: () => headerResolvers[0](true), + }, + { + outcome: 'false', + complete: () => headerResolvers[0](false), + }, + { + outcome: 'rejection', + complete: () => + headerRejectors[0](new Error('destroyed header IPC failed')), + }, + ])( + 'invalidates a pending handoff before destruction and ignores its $outcome', + async ({ complete }) => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + holdHeaderHandoff = true; + click('playback-recommendation-html5'); + fixture.detectChanges(); + const diagnostic = component.playbackDiagnostic(); + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + + expect(component.activeBinding()).not.toBeNull(); + expect(component.recoveryPending()).toBe(true); + expect(component.channel).toBeUndefined(); + fixture.destroy(); + + expect(component.activeBinding()).toBeNull(); + expect(component.recoveryPending()).toBe(false); + const destroyedState = { + binding: component.activeBinding(), + pending: component.recoveryPending(), + diagnostic: component.playbackDiagnostic(), + channel: component.channel, + vjsOptions: component.vjsOptions, + failures: [...failures], + }; + + complete(); + await Promise.resolve(); + + expect({ + binding: component.activeBinding(), + pending: component.recoveryPending(), + diagnostic: component.playbackDiagnostic(), + channel: component.channel, + vjsOptions: component.vjsOptions, + failures, + }).toEqual(destroyedState); + expect(component.playbackDiagnostic()).toBe(diagnostic); + } + ); + + it('releases web headers and diagnostics across a same-instance Embedded MPV transition', async () => { + setPlayback({ streamUrl: 'https://example.com/source-a.m3u8' }); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs', 'source-a.m3u8')); + fixture.detectChanges(); + expect(component.playbackDiagnostic()).not.toBeNull(); + + fixture.componentRef.setInput( + 'playerOverride', + VideoPlayer.EmbeddedMpv + ); + fixture.detectChanges(); + + expect(streamHeaders.clear).toHaveBeenCalledWith( + 'https://example.com/source-a.m3u8' + ); + expect(component.activeBinding()).toBeNull(); + expect(component.playbackDiagnostic()).toBeNull(); + + setPlayback({ streamUrl: 'https://example.com/source-b.m3u8' }); + fixture.componentRef.setInput('playerOverride', VideoPlayer.VideoJs); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(streamHeaders.apply).toHaveBeenLastCalledWith( + expect.objectContaining({ + streamUrl: 'https://example.com/source-b.m3u8', + }) + ); + expect(vjs().options()).toEqual( + expect.objectContaining({ + sources: [ + expect.objectContaining({ + src: 'https://example.com/source-b.m3u8', + }), + ], + }) + ); + expect(component.playbackDiagnostic()).toBeNull(); + vjs().playbackIssue.emit(mediaIssue('videojs', 'source-b.m3u8')); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.sourceUrl).toBe( + 'https://example.com/source-b.m3u8' + ); + }); + + it.each([ + { target: 'mpv' as const, testId: 'playback-fallback-mpv' }, + { target: 'vlc' as const, testId: 'playback-fallback-vlc' }, + ])( + 'emits one exact $target request for a same-tick double activation', + async ({ target, testId }) => { + const requests: unknown[] = []; + component.externalFallbackRequested.subscribe((request) => + requests.push(request) + ); + await render(); + const issue = mediaIssue('videojs'); + vjs().playbackIssue.emit(issue); + fixture.detectChanges(); + const button = requiredButton(testId); + + button.click(); + button.click(); + + expect(requests).toEqual([ + { + player: target, + playback: component.resolvedPlayback(), + diagnostic: issue, + }, + ]); + } + ); + + it.each([ + { target: 'mpv' as const, testId: 'playback-fallback-mpv' }, + { target: 'vlc' as const, testId: 'playback-fallback-vlc' }, + ])( + 'rejects a stale $target activation after playback becomes DRM protected', + async ({ testId }) => { + const requests: unknown[] = []; + component.externalFallbackRequested.subscribe((request) => + requests.push(request) + ); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + const staleButton = requiredButton(testId); + + setPlayback({ + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { + '00112233445566778899aabbccddeeff': + 'ffeeddccbbaa99887766554433221100', + }, + }, + }); + staleButton.click(); + + expect(requests).toEqual([]); + } + ); + + it('clears a diagnostic only after the current header handoff succeeds', async () => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + holdHeaderHandoff = true; + + setPlayback({ streamUrl: 'https://example.com/alternate.m3u8' }); + fixture.detectChanges(); + setPlayback({ streamUrl: 'https://example.com/current.m3u8' }); + fixture.detectChanges(); + expect(headerResolvers).toHaveLength(2); + + headerResolvers[0](true); + await fixture.whenStable(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).not.toBeNull(); + + headerResolvers[1](false); + await fixture.whenStable(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).not.toBeNull(); + + setPlayback({ streamUrl: 'https://example.com/final.m3u8' }); + fixture.detectChanges(); + headerResolvers[2](true); + await fixture.whenStable(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + expect(vjs().options()).toEqual( + expect.objectContaining({ + sources: [ + expect.objectContaining({ + src: 'https://example.com/final.m3u8', + }), + ], + }) + ); + }); + + async function render(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + async function switchToHtml5(): Promise { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + async function expectBindingChange(change: () => void): Promise { + const binding = component.activeBinding(); + change(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.activeBinding()).not.toBeNull(); + expect(component.activeBinding()).not.toBe(binding); + } + + function setPlayback(overrides: Partial): void { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/live.m3u8', + title: 'Recovery stream', + isLive: false, + ...overrides, + }); + } + + function vjs(): StubVjsPlayerComponent { + return fixture.debugElement.query(By.directive(StubVjsPlayerComponent)) + .componentInstance as StubVjsPlayerComponent; + } + + function html5(): StubHtmlVideoPlayerComponent { + return fixture.debugElement.query( + By.directive(StubHtmlVideoPlayerComponent) + ).componentInstance as StubHtmlVideoPlayerComponent; + } + + function query(testId: string): HTMLElement | null { + return fixture.nativeElement.querySelector( + `[data-test-id="${testId}"]` + ); + } + + function click(testId: string): void { + requiredButton(testId).click(); + } + + function requiredButton(testId: string): HTMLButtonElement { + const button = query(testId); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected ${testId} button`); + } + return button; + } + + function playerActionIds(): string[] { + return Array.from( + fixture.nativeElement.querySelectorAll( + '.web-player-diagnostic__player-card' + ) as NodeListOf + ).map((element) => element.dataset['testId'] ?? ''); + } +}); + +function mediaIssue( + player: 'videojs' | 'html5', + path = 'live.m3u8' +): PlaybackDiagnostic { + return { + code: PlaybackDiagnosticCode.MediaDecodeError, + source: PlaybackDiagnosticSource.Vhs, + sourceUrl: `https://example.com/${path}`, + container: 'm3u8', + mimeType: 'application/x-mpegURL', + player, + audioCodecs: [], + videoCodecs: [], + }; +} + +function networkIssue(player: 'videojs' | 'html5'): PlaybackDiagnostic { + return { + ...mediaIssue(player), + code: PlaybackDiagnosticCode.NetworkError, + }; +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss index 42a9728fb..7f7189311 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss @@ -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; - } -} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts index f451814db..b62123766 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts @@ -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, }; } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 9225fe644..ba24a270c 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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":""}', - 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, }; } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 24c907c8c..59a2cca2f 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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(); + readonly streamUrl = input.required(); readonly playbackSessionKey = input.required(); - title = input(''); - playback = input(null); - startTime = input(0); - volume = input(1); - playerOverride = input(null); - seriesNavigation = input(null); - /** Display-ready title lines for the fullscreen overlay; hosts with richer - * context (e.g. series name + episode label) pass it explicitly. */ - mediaTitle = input(null); + readonly title = input(''); + readonly playback = input(null); + readonly startTime = input(0); + readonly volume = input(1); + readonly playerOverride = input(null); + readonly seriesNavigation = input(null); + readonly mediaTitle = input(null); + readonly alternativeSources = input([]); + readonly timeUpdate = output<{ currentTime: number; duration: number; }>(); readonly externalFallbackRequested = output(); - /** - * 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([]); readonly alternativeSourceRequested = output(); - /** The row's Check action, which has to reach the host that can probe. */ readonly sourceCheckRequested = output(); - /** - * 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(); - - /** - * 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(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); - 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; 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(null); + readonly recoveryPending = this.recoverySession.switchPending; + readonly activeBinding = this.recoverySession.activeBinding; + + channel: Channel | undefined; + vjsOptions: VideoPlayerOptions | undefined; + + readonly selectedPlayer = computed(() => { + 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(() => { - 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(() => { - 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(() => { + 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); + } } } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index 7e1980e55..bc58931ba 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -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: '
', +}) export class StubVjsPlayerComponent { readonly options = input(); readonly mediaTitle = input(null); @@ -23,7 +26,10 @@ export class StubVjsPlayerComponent { readonly nextEpisodeRequested = output(); } -@Component({ selector: 'app-html-video-player', template: '' }) +@Component({ + selector: 'app-html-video-player', + template: '
', +}) export class StubHtmlVideoPlayerComponent { readonly channel = input(); readonly mediaTitle = input(null); @@ -40,7 +46,10 @@ export class StubHtmlVideoPlayerComponent { readonly nextEpisodeRequested = output(); } -@Component({ selector: 'app-art-player', template: '' }) +@Component({ + selector: 'app-art-player', + template: '
', +}) export class StubArtPlayerComponent { readonly channel = input(); readonly mediaTitle = input(null); @@ -57,7 +66,10 @@ export class StubArtPlayerComponent { readonly nextEpisodeRequested = output(); } -@Component({ selector: 'app-embedded-mpv-player', template: '' }) +@Component({ + selector: 'app-embedded-mpv-player', + template: '
', +}) export class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); readonly mediaTitle = input(null);