From b1e9aeaf7bdd987b841723a715be0857f9f5fff5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Jul 2026 19:16:37 +0200 Subject: [PATCH] feat(playback): add Shaka DASH source engine with ClearKey support Introduces ShakaVideoSession (libs/ui/playback/src/lib/shaka-engine/): a lazily imported shaka-player engine (separate lazy chunk, ~217 KB transfer) owning attach/configure/load with an operation queue and generation guard against channel-switch races. Channel ClearKey config maps to drm.clearKeys; channels with an unsupported license type emit a DrmOrEncryption diagnostic without starting an engine. Shaka errors are classified into the existing playback diagnostics (PlaybackDiagnosticSource.Shaka). Wires the engine into both built-in players like hls.js/mpegts.js: - HTML5: extension === 'mpd' branch in playChannel(); hls/mpegts/native glue extracted to helpers to keep the component within the size budget - ArtPlayer: customType 'mpd' in ArtPlayerSourceSession (+ getDrm seam) - Shared controls: WebVideoControlsSource kind 'shaka' + WebVideoShakaControls using the Shaka 5 text model (selectTextTrack(null) hides subtitles; Player.setTextTrackVisibility no longer exists) Adds a CJS shaka-player jest stub (video.js precedent) for web specs. Co-Authored-By: Claude Fable 5 --- apps/web/jest.config.ts | 1 + apps/web/src/test-stubs/shaka-player.js | 91 +++++ jest.web-esm.workspace.ts | 1 + .../lib/art-player/art-player-setup.spec.ts | 11 + .../src/lib/art-player/art-player-setup.ts | 2 + .../art-player-source-session.spec.ts | 150 ++++++++ .../art-player/art-player-source-session.ts | 34 ++ .../lib/art-player/art-player.component.ts | 1 + .../html-video-player-diagnostics.ts | 87 +++++ ....component.shared-controls.sources.spec.ts | 31 ++ ...component.shared-controls.spec-fixtures.ts | 2 +- .../html-video-player.component.ts | 166 ++++----- .../playback-diagnostics.model.ts | 1 + .../playback-diagnostics.util.ts | 34 +- .../shaka-error-classifier.spec.ts | 99 +++++ .../shaka-engine/shaka-error-classifier.ts | 147 ++++++++ .../lib/shaka-engine/shaka-module.types.ts | 75 ++++ .../shaka-engine/shaka-video-session.spec.ts | 278 ++++++++++++++ .../lib/shaka-engine/shaka-video-session.ts | 338 ++++++++++++++++++ .../web-player-view.component.ts | 3 + .../exit-owned-fullscreen.util.ts | 23 ++ .../web-video-native-source.util.ts | 22 ++ .../web-video-shaka-controls.ts | 136 +++++++ .../web-video-source-controls.bridge.ts | 33 +- package.json | 1 + pnpm-lock.yaml | 145 +++++++- 26 files changed, 1775 insertions(+), 137 deletions(-) create mode 100644 apps/web/src/test-stubs/shaka-player.js create mode 100644 libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/exit-owned-fullscreen.util.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/web-video-native-source.util.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index b6ba9eba2..fe4335511 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -39,6 +39,7 @@ export default { tslib: 'tslib/tslib.es6.js', '^iptv-playlist-parser$': '/src/test-stubs/iptv-playlist-parser.mjs', + '^shaka-player$': '/src/test-stubs/shaka-player.js', '^video.js$': '/src/test-stubs/video-js.js', '^rxjs': '/../../node_modules/rxjs/dist/bundles/rxjs.umd.js', '^uuid$': '/../../node_modules/uuid/wrapper.mjs', diff --git a/apps/web/src/test-stubs/shaka-player.js b/apps/web/src/test-stubs/shaka-player.js new file mode 100644 index 000000000..216bc07a3 --- /dev/null +++ b/apps/web/src/test-stubs/shaka-player.js @@ -0,0 +1,91 @@ +/** + * CJS test stub for the lazily imported `shaka-player` module (the real + * compiled bundle is huge and ESM-hostile under jest). Mirrors the surface in + * libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts. + * + * Specs can inspect `shaka.__instances` / reset with `shaka.__reset()`. + */ +class MockShakaPlayer { + constructor() { + this.configureCalls = []; + this.loadedUrls = []; + this.attachedTo = null; + this.destroyCount = 0; + this.selectTextTrackCalls = []; + this.listeners = new Map(); + shaka.__instances.push(this); + } + + attach(mediaElement) { + this.attachedTo = mediaElement; + return Promise.resolve(); + } + + configure(config) { + this.configureCalls.push(config); + return true; + } + + load(assetUri) { + this.loadedUrls.push(assetUri); + return Promise.resolve(); + } + + destroy() { + this.destroyCount += 1; + return Promise.resolve(); + } + + addEventListener(type, listener) { + const set = this.listeners.get(type) || new Set(); + set.add(listener); + this.listeners.set(type, set); + } + + removeEventListener(type, listener) { + const set = this.listeners.get(type); + if (set) { + set.delete(listener); + } + } + + dispatch(type, detail) { + for (const listener of this.listeners.get(type) || []) { + listener({ type, detail }); + } + } + + getAudioTracks() { + return []; + } + + selectAudioTrack() {} + + getTextTracks() { + return []; + } + + selectTextTrack(track) { + this.selectTextTrackCalls.push(track); + } + + isLive() { + return false; + } +} + +MockShakaPlayer.isBrowserSupported = () => true; + +const shaka = { + Player: MockShakaPlayer, + polyfill: { + installAll() {}, + }, + __instances: [], + __reset() { + shaka.__instances.length = 0; + }, +}; + +module.exports = shaka; +module.exports.default = shaka; diff --git a/jest.web-esm.workspace.ts b/jest.web-esm.workspace.ts index d10d1b943..d22c06e22 100644 --- a/jest.web-esm.workspace.ts +++ b/jest.web-esm.workspace.ts @@ -31,6 +31,7 @@ export default { tslib: 'tslib/tslib.es6.js', '^iptv-playlist-parser$': '/apps/web/src/test-stubs/iptv-playlist-parser.mjs', + '^shaka-player$': '/apps/web/src/test-stubs/shaka-player.js', '^rxjs': '/node_modules/rxjs/dist/bundles/rxjs.umd.js', '^uuid$': '/node_modules/uuid/wrapper.mjs', }, diff --git a/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts index cf42526bc..f02a4b006 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-setup.spec.ts @@ -83,6 +83,17 @@ describe('ArtPlayer setup', () => { ); }); + it('routes DASH manifests to the mpd custom type', () => { + expect(getArtPlayerVideoType('https://example.test/live.mpd')).toBe( + 'mpd' + ); + expect( + getArtPlayerVideoType( + 'https://example.test/live.mpd?token=signed#frag' + ) + ).toBe('mpd'); + }); + it('exits fullscreen only when the shared ArtPlayer shell owns it', () => { const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor( document, diff --git a/libs/ui/playback/src/lib/art-player/art-player-setup.ts b/libs/ui/playback/src/lib/art-player/art-player-setup.ts index 2e3b0cebe..9e78fe990 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-setup.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-setup.ts @@ -72,6 +72,8 @@ export function getArtPlayerVideoType(url: string): string { return 'm3u8'; case 'mp4': return 'mp4'; + case 'mpd': + return 'mpd'; case 'ts': return 'ts'; default: diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts index bd1f1a8ee..d390bb954 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts @@ -1,6 +1,11 @@ import type Artplayer from 'artplayer'; +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; import { WebVideoControlsAdapter } from '../player-controls'; +import type { + ShakaModuleLike, + ShakaModuleLoader, +} from '../shaka-engine/shaka-module.types'; import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session'; const hlsInstances: MockHls[] = []; @@ -335,16 +340,159 @@ describe('ArtPlayerSourceSession', () => { expect(refresh).toHaveBeenCalled(); }); + + it('starts the Shaka engine with channel DRM for the mpd custom type', async () => { + const fakeShaka = createFakeShakaModule(); + const drm: ChannelDrm = { + licenseType: 'clearkey', + supported: true, + clearKeys: { abc: 'def' }, + }; + const { session, player, video } = createSession({ + sharedControls: true, + getDrm: () => drm, + loadShaka: fakeShaka.loader, + }); + session.attach(player); + + session.customType['mpd']?.( + video, + 'https://example.test/live.mpd', + player + ); + await flushAsync(); + + expect(fakeShaka.loader).toHaveBeenCalledTimes(1); + expect(fakeShaka.instances).toHaveLength(1); + expect(fakeShaka.instances[0].attachedTo).toBe(video); + expect(fakeShaka.instances[0].loadedUrls).toEqual([ + 'https://example.test/live.mpd', + ]); + expect(fakeShaka.instances[0].configureCalls).toEqual([ + { drm: { clearKeys: { abc: 'def' } } }, + ]); + expect(hlsInstances).toHaveLength(0); + expect(mpegTsInstances).toHaveLength(0); + }); + + it('destroys the Shaka engine when the source changes or the session dies', async () => { + const fakeShaka = createFakeShakaModule(); + const { session, player, video } = createSession({ + sharedControls: false, + loadShaka: fakeShaka.loader, + }); + session.attach(player); + + session.customType['mpd']?.( + video, + 'https://example.test/live.mpd', + player + ); + await flushAsync(); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + await flushAsync(); + + expect(fakeShaka.instances[0].destroyCount).toBe(1); + expect(hlsInstances).toHaveLength(1); + + session.destroy(); + await flushAsync(); + expect(fakeShaka.instances).toHaveLength(1); + }); }); +async function flushAsync(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } +} + +interface FakeShakaPlayerInstance { + attachedTo: HTMLMediaElement | null; + loadedUrls: string[]; + configureCalls: Record[]; + destroyCount: number; +} + +function createFakeShakaModule(): { + loader: jest.Mock; + instances: FakeShakaPlayerInstance[]; +} { + const instances: FakeShakaPlayerInstance[] = []; + + class FakePlayer { + static isBrowserSupported = () => true; + attachedTo: HTMLMediaElement | null = null; + loadedUrls: string[] = []; + configureCalls: Record[] = []; + destroyCount = 0; + + constructor() { + instances.push(this); + } + + attach(mediaElement: HTMLMediaElement): Promise { + this.attachedTo = mediaElement; + return Promise.resolve(); + } + configure(config: Record): boolean { + this.configureCalls.push(config); + return true; + } + load(assetUri: string): Promise { + this.loadedUrls.push(assetUri); + return Promise.resolve(); + } + destroy(): Promise { + this.destroyCount += 1; + return Promise.resolve(); + } + addEventListener(): void {} + removeEventListener(): void {} + getAudioTracks() { + return []; + } + selectAudioTrack(): void {} + getTextTracks() { + return []; + } + selectTextTrack(): void {} + setTextTrackVisibility(): void {} + isTextVisible(): boolean { + return false; + } + isLive(): boolean { + return false; + } + } + + const module = { + Player: FakePlayer, + polyfill: { installAll: () => undefined }, + } as unknown as ShakaModuleLike; + + return { + loader: jest.fn().mockResolvedValue(module), + instances, + }; +} + function createSession({ sharedControls, isLive = true, emitPlaybackIssue = () => undefined, + getDrm, + loadShaka, }: { sharedControls: boolean; isLive?: boolean; emitPlaybackIssue?: (issue: PlaybackDiagnostic) => void; + getDrm?: () => ChannelDrm | undefined; + loadShaka?: ShakaModuleLoader; }): { session: ArtPlayerSourceSessionInstance; player: Artplayer; @@ -368,6 +516,8 @@ function createSession({ isLive: () => isLive, showCaptions: () => false, emitPlaybackIssue, + getDrm, + loadShaka, }), player, video, diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts index 7040409e1..def6b1be7 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts @@ -2,6 +2,7 @@ import type Artplayer from 'artplayer'; import type { Option } from 'artplayer'; import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js'; import mpegts from 'mpegts.js'; +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; import { InlinePlaybackPlayer, type PlaybackDiagnostic, @@ -11,6 +12,8 @@ import { createPlaybackSourceMetadata, } from '../playback-diagnostics/playback-diagnostics.util'; import type { WebVideoControlsAdapter } from '../player-controls'; +import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types'; +import { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; import { type WebVideoControlsSource, WebVideoSourceControlsBridge, @@ -23,6 +26,10 @@ export interface ArtPlayerSourceSessionConfig { isLive: () => boolean; showCaptions: () => boolean; emitPlaybackIssue: (issue: PlaybackDiagnostic) => void; + /** DRM config of the active channel, used by the DASH (`mpd`) engine. */ + getDrm?: () => ChannelDrm | undefined; + /** Test seam for the lazily imported shaka-player module. */ + loadShaka?: ShakaModuleLoader; } /** @@ -35,6 +42,7 @@ export interface ArtPlayerSourceSessionConfig { export class ArtPlayerSourceSession { readonly customType: NonNullable = { m3u8: (video, url, art) => this.startHls(video, url, art), + mpd: (video, url) => this.startDash(video, url), ts: (video, url) => this.startMpegTs(video, url), 'video/matroska': (video, url) => this.startNative(video, url), }; @@ -53,6 +61,7 @@ export class ArtPlayerSourceSession { private mpegTsErrorListener: | ((type: string, details: string, info: unknown) => void) | null = null; + private shakaSession: ShakaVideoSession | null = null; private destroyed = false; constructor(private readonly config: ArtPlayerSourceSessionConfig) {} @@ -99,6 +108,8 @@ export class ArtPlayerSourceSession { this.controlsBridge = null; this.destroyHls(); this.destroyMpegTs(); + this.shakaSession?.destroy(); + this.shakaSession = null; this.player = null; } @@ -181,6 +192,17 @@ export class ArtPlayerSourceSession { } } + private startDash(video: HTMLVideoElement, url: string): void { + if (this.destroyed) { + return; + } + + this.prepareForSourceChange(); + const session = this.getShakaSession(); + this.bindControlsSource({ kind: 'shaka', session }); + session.start(video, url, this.config.getDrm?.()); + } + private startNative(video: HTMLVideoElement, url: string): void { if (this.destroyed) { return; @@ -195,6 +217,18 @@ export class ArtPlayerSourceSession { this.controlsBridge?.clearSource(); this.destroyHls(); this.destroyMpegTs(); + this.shakaSession?.stop(); + } + + private getShakaSession(): ShakaVideoSession { + this.shakaSession ??= new ShakaVideoSession({ + player: InlinePlaybackPlayer.ArtPlayer, + emitPlaybackIssue: (issue) => + this.config.emitPlaybackIssue(issue), + showCaptions: this.config.showCaptions, + loadShaka: this.config.loadShaka, + }); + return this.shakaSession; } private bindControlsSource(source: WebVideoControlsSource): void { diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 5646dd4b4..7695452a5 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -134,6 +134,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { isLive: () => this.isLive(), showCaptions: () => this.showCaptions(), emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue), + getDrm: () => this.channel().drm, }); this.sourceSession = sourceSession; diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts new file mode 100644 index 000000000..4c5284892 --- /dev/null +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts @@ -0,0 +1,87 @@ +import type { ErrorData, ManifestParsedData } from 'hls.js'; +import { + InlinePlaybackPlayer, + type PlaybackDiagnostic, + type PlaybackSourceMetadata, + classifyHlsPlaybackIssue, + classifyMpegTsPlaybackIssue, + classifyUnsupportedHlsManifestCodecs, + createPlaybackSourceMetadata, +} from '../playback-diagnostics/playback-diagnostics.util'; + +/** + * HTML5-player diagnostics glue extracted from the component: builds source + * metadata and turns engine error payloads into emitted playback issues. + */ + +export function createHtml5SourceMetadata( + url: string, + mimeType?: string, + audioCodecs: readonly string[] = [], + videoCodecs: readonly string[] = [] +): PlaybackSourceMetadata { + return createPlaybackSourceMetadata({ + url, + mimeType, + player: InlinePlaybackPlayer.Html5, + audioCodecs, + videoCodecs, + }); +} + +export function emitUnsupportedHlsManifestCodecs( + url: string, + data: ManifestParsedData, + emitPlaybackIssue: (issue: PlaybackDiagnostic) => void +): void { + const metadata = createHtml5SourceMetadata( + url, + 'application/x-mpegURL', + data.levels + .map((level) => level.audioCodec) + .filter((codec): codec is string => Boolean(codec)), + data.levels + .map((level) => level.videoCodec) + .filter((codec): codec is string => Boolean(codec)) + ); + const issue = classifyUnsupportedHlsManifestCodecs(metadata); + if (issue) { + emitPlaybackIssue(issue); + } +} + +export function emitFatalHlsPlaybackError( + url: string, + data: ErrorData, + emitPlaybackIssue: (issue: PlaybackDiagnostic) => void +): void { + if (!data.fatal) { + return; + } + + emitPlaybackIssue( + classifyHlsPlaybackIssue( + { + type: data.type, + details: data.details, + fatal: data.fatal, + message: data.error?.message, + error: data.error, + }, + createHtml5SourceMetadata(url, 'application/x-mpegURL') + ) + ); +} + +export function emitMpegTsPlaybackError( + url: string, + error: { type: string; details: string; info: unknown }, + emitPlaybackIssue: (issue: PlaybackDiagnostic) => void +): void { + emitPlaybackIssue( + classifyMpegTsPlaybackIssue( + error, + createHtml5SourceMetadata(url, 'video/mp2t') + ) + ); +} 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 9b336e083..1c82ec2b4 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 @@ -126,6 +126,37 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => { ); }); + it('owns one Shaka source for DASH (.mpd) streams', () => { + const { component } = renderSharedControls( + HtmlVideoPlayerComponent, + fixtures + ); + const setSource = observeBridgeSourceBinding(component); + + component.playChannel({ + ...TEST_CHANNEL, + url: 'https://example.test/live.mpd', + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { abc: 'def' }, + }, + }); + + const internals = readHtmlPlayerInternals(component); + expect(setSource).toHaveBeenCalledTimes(1); + expect(setSource).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'shaka' }) + ); + expect(internals.controlsSource).toEqual( + expect.objectContaining({ kind: 'shaka' }) + ); + expect(internals.hls).toBeNull(); + expect(internals.mpegtsPlayer).toBeNull(); + expect(hlsInstances).toHaveLength(0); + expect(mpegTsInstances).toHaveLength(0); + }); + it('owns one native source before loading native media', () => { const { component, fixture } = renderSharedControls( HtmlVideoPlayerComponent, diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts index 926b5f850..ade52f86e 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.spec-fixtures.ts @@ -19,7 +19,7 @@ export type SharedControlsFixture = ComponentFixture; export interface ControlsSource { - kind: 'native' | 'mpegts' | 'hls'; + kind: 'native' | 'mpegts' | 'hls' | 'shaka'; } export interface HtmlPlayerInternals { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index b13eddd87..8643513a7 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -20,10 +20,6 @@ import { Channel, createDevLogger } from '@iptvnator/shared/interfaces'; import { InlinePlaybackPlayer, PlaybackDiagnostic, - classifyHlsPlaybackIssue, - classifyMpegTsPlaybackIssue, - classifyUnsupportedHlsManifestCodecs, - createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; import { @@ -33,7 +29,18 @@ import { } from '../player-controls'; import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; +import { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; +import { exitOwnedFullscreen } from '../web-video-support/exit-owned-fullscreen.util'; +import { + clearNativeVideoSources, + setNativeVideoSource, +} from '../web-video-support/web-video-native-source.util'; import { HtmlVideoElementSession } from './html-video-element-session'; +import { + emitFatalHlsPlaybackError, + emitMpegTsPlaybackError, + emitUnsupportedHlsManifestCodecs, +} from './html-video-player-diagnostics'; import { HtmlVideoPlayerControlsBridge, type HtmlVideoControlsSource, @@ -88,6 +95,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { hls: Hls | null = null; /** mpegts.js player for raw MPEG-TS streams */ private mpegtsPlayer: mpegts.Player | null = null; + /** Shaka session for DASH (.mpd) streams, created on first use */ + private shakaSession: ShakaVideoSession | null = null; private controlsSource: HtmlVideoControlsSource | null = null; private controlsBridge: HtmlVideoPlayerControlsBridge | null = null; private videoSession: HtmlVideoElementSession | null = null; @@ -127,7 +136,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.controlsBridge?.refreshInputs(); } if (changes['interactionEnabled']?.currentValue === false) { - this.exitOwnedFullscreen(); + exitOwnedFullscreen( + this.sharedControls, + this.playerRoot()?.nativeElement, + (error) => + debugHtmlPlayer( + 'Failed to exit HTML5 player fullscreen:', + error + ) + ); } if (changes['volume']?.currentValue !== undefined) { debugHtmlPlayer( @@ -139,29 +156,6 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { } } - private exitOwnedFullscreen(): void { - if ( - !this.sharedControls || - document.fullscreenElement !== this.playerRoot()?.nativeElement || - typeof document.exitFullscreen !== 'function' - ) { - return; - } - - try { - void Promise.resolve(document.exitFullscreen()).catch( - (error: unknown) => { - debugHtmlPlayer( - 'Failed to exit HTML5 player fullscreen:', - error - ); - } - ); - } catch (error: unknown) { - debugHtmlPlayer('Failed to exit HTML5 player fullscreen:', error); - } - } - /** * Starts to play the given channel * @param channel given channel object @@ -170,7 +164,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.clearControlsSource(); this.destroyMpegtsPlayer(); this.destroyHls(); - this.clearNativeVideoSources(this.videoPlayer.nativeElement); + this.shakaSession?.stop(); + clearNativeVideoSources(this.videoPlayer.nativeElement); if (channel.url) { this.playbackIssue.emit(null); const url = channel.url + (channel.epgParams ?? ''); @@ -189,7 +184,24 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { ); }); - if ((extension === 'ts' || !extension) && mpegts.isSupported()) { + if (extension === 'mpd') { + debugHtmlPlayer( + 'Using Shaka Player for DASH stream:', + channel.name, + url + ); + const session = this.getShakaSession(); + this.bindControlsSource({ kind: 'shaka', session }); + session.start( + this.videoPlayer.nativeElement, + url, + channel.drm + ); + this.handlePlayOperation(); + } else if ( + (extension === 'ts' || !extension) && + mpegts.isSupported() + ) { debugHtmlPlayer( 'Using mpegts.js for TS stream:', channel.name, @@ -207,15 +219,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.mpegtsPlayer.on( mpegts.Events.ERROR, (type: string, details: string, info: unknown): void => { - this.playbackIssue.emit( - classifyMpegTsPlaybackIssue( - { - type, - details, - info, - }, - this.createSourceMetadata(url, 'video/mp2t') - ) + emitMpegTsPlaybackError( + url, + { type, details, info }, + (issue) => this.playbackIssue.emit(issue) ); } ); @@ -242,35 +249,18 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.handlePlayOperation(); } else { debugHtmlPlayer('Using native video player'); - this.replaceNativeVideoSource( + setNativeVideoSource( this.videoPlayer.nativeElement, url, 'video/mp4' ); + this.bindControlsSource({ kind: 'native' }); + this.videoPlayer.nativeElement.load(); this.handlePlayOperation(); } } } - private clearNativeVideoSources(element: HTMLVideoElement): void { - element.removeAttribute('src'); - element.replaceChildren(); - } - - private replaceNativeVideoSource( - element: HTMLVideoElement, - url: string, - type: string - ): void { - this.clearNativeVideoSources(element); - const source = document.createElement('source'); - source.src = url; - source.type = type; - element.appendChild(source); - this.bindControlsSource({ kind: 'native' }); - element.load(); - } - private bindControlsSource(source: HtmlVideoControlsSource): void { this.controlsSource = source; this.controlsBridge?.setSource(source); @@ -299,6 +289,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { hls?.destroy(); } + private getShakaSession(): ShakaVideoSession { + this.shakaSession ??= new ShakaVideoSession({ + player: InlinePlaybackPlayer.Html5, + emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue), + showCaptions: () => this.showCaptions(), + }); + return this.shakaSession; + } + /** * Disables text based captions based on the global settings */ @@ -317,56 +316,17 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { url: string, data: ManifestParsedData ): void { - const metadata = this.createSourceMetadata( - url, - 'application/x-mpegURL', - data.levels - .map((level) => level.audioCodec) - .filter((codec): codec is string => Boolean(codec)), - data.levels - .map((level) => level.videoCodec) - .filter((codec): codec is string => Boolean(codec)) + emitUnsupportedHlsManifestCodecs(url, data, (issue) => + this.playbackIssue.emit(issue) ); - const issue = classifyUnsupportedHlsManifestCodecs(metadata); - if (issue) { - this.playbackIssue.emit(issue); - } } private handleHlsError(url: string, data: ErrorData): void { - if (!data.fatal) { - return; - } - - this.playbackIssue.emit( - classifyHlsPlaybackIssue( - { - type: data.type, - details: data.details, - fatal: data.fatal, - message: data.error?.message, - error: data.error, - }, - this.createSourceMetadata(url, 'application/x-mpegURL') - ) + emitFatalHlsPlaybackError(url, data, (issue) => + this.playbackIssue.emit(issue) ); } - private createSourceMetadata( - url: string, - mimeType?: string, - audioCodecs: readonly string[] = [], - videoCodecs: readonly string[] = [] - ) { - return createPlaybackSourceMetadata({ - url, - mimeType, - player: InlinePlaybackPlayer.Html5, - audioCodecs, - videoCodecs, - }); - } - /** * Destroy hls instance on component destroy and clean up event listener */ @@ -378,6 +338,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.videoSession = null; this.destroyMpegtsPlayer(); this.destroyHls(); + this.shakaSession?.destroy(); + this.shakaSession = null; } private getVideoSession(): HtmlVideoElementSession { diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts index 4bc4f8195..1079ab2f1 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts @@ -21,6 +21,7 @@ export const PlaybackDiagnosticSource = { Native: 'native', Hls: 'hls', MpegTs: 'mpegts', + Shaka: 'shaka', } as const; export type PlaybackDiagnosticSource = diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts index 8cf24499c..e114896f9 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts @@ -45,7 +45,7 @@ export function classifyNativePlaybackIssue( // Native MediaError details are often opaque for browser security // failures. Only classify browser access when the runtime exposes a // concrete CORS/mixed-content/CSP-style message. - return createDiagnostic({ + return createPlaybackDiagnostic({ code: isBrowserAccessFailure(lowerNativeErrorMessage) ? DiagnosticCode.BrowserAccessError : DiagnosticCode.NetworkError, @@ -57,7 +57,7 @@ export function classifyNativePlaybackIssue( } if (nativeErrorCode === DECODE_ERROR_CODE) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.MediaDecodeError, source: DiagnosticSource.Native, metadata, @@ -67,7 +67,7 @@ export function classifyNativePlaybackIssue( } if (nativeErrorCode === SOURCE_NOT_SUPPORTED_CODE) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: isLikelyContainerIssue(metadata) ? DiagnosticCode.UnsupportedContainer : DiagnosticCode.UnsupportedCodec, @@ -78,7 +78,7 @@ export function classifyNativePlaybackIssue( }); } - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnknownPlaybackError, source: DiagnosticSource.Native, metadata, @@ -100,7 +100,7 @@ export function classifyHlsPlaybackIssue( }); if (isNetworkFailure(lowerType, lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: isBrowserAccessFailure(lowerDetails) ? DiagnosticCode.BrowserAccessError : DiagnosticCode.NetworkError, @@ -111,7 +111,7 @@ export function classifyHlsPlaybackIssue( } if (isDrmOrEncryptionFailure(lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.DrmOrEncryption, source: DiagnosticSource.Hls, metadata: mergedMetadata, @@ -120,7 +120,7 @@ export function classifyHlsPlaybackIssue( } if (isCodecFailure(lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnsupportedCodec, source: DiagnosticSource.Hls, metadata: mergedMetadata, @@ -129,7 +129,7 @@ export function classifyHlsPlaybackIssue( } if (lowerType.includes('media') || lowerType.includes('mux')) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.MediaDecodeError, source: DiagnosticSource.Hls, metadata: mergedMetadata, @@ -137,7 +137,7 @@ export function classifyHlsPlaybackIssue( }); } - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnknownPlaybackError, source: DiagnosticSource.Hls, metadata: mergedMetadata, @@ -154,7 +154,7 @@ export function classifyMpegTsPlaybackIssue( const lowerType = (error.type ?? '').toLowerCase(); if (isEarlyEofFailure(lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.MediaDecodeError, source: DiagnosticSource.MpegTs, metadata, @@ -163,7 +163,7 @@ export function classifyMpegTsPlaybackIssue( } if (isNetworkFailure(lowerType, lowerDetails)) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: isBrowserAccessFailure(lowerDetails) ? DiagnosticCode.BrowserAccessError : DiagnosticCode.NetworkError, @@ -174,7 +174,7 @@ export function classifyMpegTsPlaybackIssue( } if (lowerDetails.includes('codec')) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnsupportedCodec, source: DiagnosticSource.MpegTs, metadata, @@ -183,7 +183,7 @@ export function classifyMpegTsPlaybackIssue( } if (lowerDetails.includes('format') || lowerDetails.includes('mse')) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnsupportedContainer, source: DiagnosticSource.MpegTs, metadata, @@ -192,7 +192,7 @@ export function classifyMpegTsPlaybackIssue( } if (lowerType.includes('media')) { - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.MediaDecodeError, source: DiagnosticSource.MpegTs, metadata, @@ -200,7 +200,7 @@ export function classifyMpegTsPlaybackIssue( }); } - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnknownPlaybackError, source: DiagnosticSource.MpegTs, metadata, @@ -222,7 +222,7 @@ export function classifyUnsupportedHlsManifestCodecs( return null; } - return createDiagnostic({ + return createPlaybackDiagnostic({ code: DiagnosticCode.UnsupportedCodec, source: DiagnosticSource.Source, metadata, @@ -230,7 +230,7 @@ export function classifyUnsupportedHlsManifestCodecs( }); } -function createDiagnostic(options: { +export function createPlaybackDiagnostic(options: { readonly code: PlaybackDiagnosticCode; readonly source: PlaybackDiagnosticSource; readonly metadata: PlaybackSourceMetadata; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts new file mode 100644 index 000000000..f78f1dc83 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts @@ -0,0 +1,99 @@ +import { + InlinePlaybackPlayer, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, +} from '../playback-diagnostics/playback-diagnostics.model'; +import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util'; +import { + classifyShakaPlaybackIssue, + createUnsupportedDrmDiagnostic, +} from './shaka-error-classifier'; + +const metadata = createPlaybackSourceMetadata({ + url: 'http://example.com/stream.mpd', + mimeType: 'application/dash+xml', + player: InlinePlaybackPlayer.Html5, +}); + +describe('classifyShakaPlaybackIssue', () => { + it.each([ + ['DRM category', { category: 6, code: 6001 }], + ['restrictions-cannot-be-met', { category: 4, code: 4012 }], + [ + 'license keyword in message', + { category: 5, code: 5001, message: 'license request failed' }, + ], + ])('maps %s to DrmOrEncryption', (_label, error) => { + const issue = classifyShakaPlaybackIssue(error, metadata); + expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka); + }); + + it('maps network category to NetworkError', () => { + const issue = classifyShakaPlaybackIssue( + { category: 1, code: 1001 }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); + }); + + it('maps CORS-flavored network failures to BrowserAccessError', () => { + const issue = classifyShakaPlaybackIssue( + { category: 1, code: 1002, message: 'Blocked by CORS policy' }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.BrowserAccessError); + }); + + it('maps media category to MediaDecodeError', () => { + const issue = classifyShakaPlaybackIssue( + { category: 3, code: 3016 }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); + }); + + it('maps codec-flavored media failures to UnsupportedCodec', () => { + const issue = classifyShakaPlaybackIssue( + { category: 3, code: 3005, message: 'addCodec failed for hvc1' }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); + }); + + it('maps manifest category to UnsupportedContainer', () => { + const issue = classifyShakaPlaybackIssue( + { category: 4, code: 4001 }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer); + }); + + it('falls back to UnknownPlaybackError and keeps details', () => { + const issue = classifyShakaPlaybackIssue( + { category: 7, code: 7002, message: 'boom' }, + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); + expect(issue.details).toContain('boom'); + expect(issue.details).toContain('7002'); + }); + + it('tolerates missing error objects', () => { + const issue = classifyShakaPlaybackIssue(null, metadata); + expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); + }); +}); + +describe('createUnsupportedDrmDiagnostic', () => { + it('creates a DRM diagnostic carrying the license type', () => { + const issue = createUnsupportedDrmDiagnostic( + 'com.widevine.alpha', + metadata + ); + expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka); + expect(issue.details).toContain('com.widevine.alpha'); + expect(issue.externalFallbackRecommended).toBe(true); + }); +}); diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts new file mode 100644 index 000000000..b4cbec360 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts @@ -0,0 +1,147 @@ +import type { + PlaybackDiagnostic, + PlaybackSourceMetadata, +} from '../playback-diagnostics/playback-diagnostics.model'; +import { + PlaybackDiagnosticCode as DiagnosticCode, + PlaybackDiagnosticSource as DiagnosticSource, +} from '../playback-diagnostics/playback-diagnostics.model'; +import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import { + isBrowserAccessFailure, + isCodecFailure, + isDrmOrEncryptionFailure, +} from '../playback-diagnostics/playback-error-patterns.util'; +import type { ShakaErrorLike } from './shaka-module.types'; + +/** + * Numeric `shaka.util.Error.Category` values (stable public Shaka API). Kept + * local so classification stays a pure function that does not need the lazily + * loaded module. + */ +const SHAKA_CATEGORY = { + Network: 1, + Text: 2, + Media: 3, + Manifest: 4, + Streaming: 5, + Drm: 6, +} as const; + +/** `shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET` — in practice this fires + * when every variant is restricted by unusable decryption keys. */ +const RESTRICTIONS_CANNOT_BE_MET = 4012; + +export function classifyShakaPlaybackIssue( + error: Partial | null | undefined, + metadata: PlaybackSourceMetadata +): PlaybackDiagnostic { + const details = formatShakaErrorDetails(error); + const lowerDetails = details.toLowerCase(); + const category = error?.category; + + if ( + category === SHAKA_CATEGORY.Drm || + error?.code === RESTRICTIONS_CANNOT_BE_MET || + isDrmOrEncryptionFailure(lowerDetails) + ) { + return createPlaybackDiagnostic({ + code: DiagnosticCode.DrmOrEncryption, + source: DiagnosticSource.Shaka, + metadata, + details, + }); + } + + if (category === SHAKA_CATEGORY.Network) { + return createPlaybackDiagnostic({ + code: isBrowserAccessFailure(lowerDetails) + ? DiagnosticCode.BrowserAccessError + : DiagnosticCode.NetworkError, + source: DiagnosticSource.Shaka, + metadata, + details, + }); + } + + if ( + category === SHAKA_CATEGORY.Media || + category === SHAKA_CATEGORY.Streaming + ) { + return createPlaybackDiagnostic({ + code: isCodecFailure(lowerDetails) + ? DiagnosticCode.UnsupportedCodec + : DiagnosticCode.MediaDecodeError, + source: DiagnosticSource.Shaka, + metadata, + details, + }); + } + + if (category === SHAKA_CATEGORY.Manifest) { + return createPlaybackDiagnostic({ + code: DiagnosticCode.UnsupportedContainer, + source: DiagnosticSource.Shaka, + metadata, + details, + }); + } + + return createPlaybackDiagnostic({ + code: DiagnosticCode.UnknownPlaybackError, + source: DiagnosticSource.Shaka, + metadata, + details, + }); +} + +/** + * Diagnostic for `.mpd` channels that declare a DRM system the app cannot + * handle (Widevine, PlayReady, malformed ClearKey config, …). Emitted before + * any Shaka engine is started. + */ +export function createUnsupportedDrmDiagnostic( + licenseType: string, + metadata: PlaybackSourceMetadata +): PlaybackDiagnostic { + return createPlaybackDiagnostic({ + code: DiagnosticCode.DrmOrEncryption, + source: DiagnosticSource.Shaka, + metadata, + details: licenseType + ? `Unsupported DRM license configuration: ${licenseType}` + : 'Unsupported DRM license configuration', + }); +} + +function formatShakaErrorDetails( + error: Partial | null | undefined +): string { + if (!error) { + return ''; + } + + const parts = [ + typeof error.category === 'number' + ? `Shaka category ${error.category}` + : '', + typeof error.code === 'number' ? `code ${error.code}` : '', + error.message ?? '', + stringifyErrorData(error.data), + ]; + + return parts.filter((part) => part.length > 0).join(' '); +} + +function stringifyErrorData(data: unknown[] | undefined): string { + if (!data || data.length === 0) { + return ''; + } + + try { + const serialized = JSON.stringify(data); + return serialized === '[]' ? '' : serialized; + } catch { + return ''; + } +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts new file mode 100644 index 000000000..50421098a --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts @@ -0,0 +1,75 @@ +/** + * Minimal structural typings for the lazily imported `shaka-player` module. + * + * The vendor package ships clutz-generated global namespace typings that are + * awkward to consume from an ESM library. These interfaces cover exactly the + * surface the app uses and double as the seam for unit-test fakes: the session + * accepts any {@link ShakaModuleLoader}, so specs never touch the real module. + */ + +export interface ShakaAudioTrackLike { + active: boolean; + language: string; + label: string | null; + roles: string[]; +} + +export interface ShakaTextTrackLike { + id: number; + active: boolean; + language: string; + label: string | null; + kind: string | null; +} + +export interface ShakaErrorLike { + severity: number; + category: number; + code: number; + message?: string; + data?: unknown[]; +} + +export interface ShakaPlayerLike { + attach(mediaElement: HTMLMediaElement): Promise; + configure(config: Record): boolean; + load(assetUri: string): Promise; + destroy(): Promise; + addEventListener(type: string, listener: (event: Event) => void): void; + removeEventListener(type: string, listener: (event: Event) => void): void; + getAudioTracks(): ShakaAudioTrackLike[]; + selectAudioTrack(track: ShakaAudioTrackLike): void; + getTextTracks(): ShakaTextTrackLike[]; + /** + * Shaka 5 visibility model: selecting a track shows it, `null` unloads + * the active track (subtitles off). There is no separate visibility API. + */ + selectTextTrack(track: ShakaTextTrackLike | null): void; + isLive(): boolean; +} + +export interface ShakaModuleLike { + Player: { + new (): ShakaPlayerLike; + isBrowserSupported(): boolean; + }; + polyfill: { + installAll(): void; + }; +} + +export type ShakaModuleLoader = () => Promise; + +/** + * Default production loader. The dynamic import keeps shaka-player (~267 KB + * gzip) out of the main bundle until the first `.mpd` stream starts. + */ +export const loadShakaModule: ShakaModuleLoader = async () => { + const module = (await import('shaka-player')) as unknown as + | { default?: ShakaModuleLike } + | ShakaModuleLike; + return ( + ((module as { default?: ShakaModuleLike }).default ?? + module) as ShakaModuleLike + ); +}; 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 new file mode 100644 index 000000000..986b394f8 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts @@ -0,0 +1,278 @@ +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; +import { + InlinePlaybackPlayer, + PlaybackDiagnostic, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, +} from '../playback-diagnostics/playback-diagnostics.model'; +import type { + ShakaModuleLike, + ShakaPlayerLike, +} from './shaka-module.types'; +import { ShakaVideoSession } from './shaka-video-session'; + +type Listener = (event: Event) => void; + +class FakeShakaPlayer implements ShakaPlayerLike { + static instances: FakeShakaPlayer[] = []; + readonly configureCalls: Record[] = []; + readonly listeners = new Map>(); + readonly selectTextTrackCalls: unknown[] = []; + attachedTo: HTMLMediaElement | null = null; + loadedUrls: string[] = []; + destroyCount = 0; + loadResult: Promise = Promise.resolve(); + + constructor() { + FakeShakaPlayer.instances.push(this); + } + + attach(mediaElement: HTMLMediaElement): Promise { + this.attachedTo = mediaElement; + return Promise.resolve(); + } + + configure(config: Record): boolean { + this.configureCalls.push(config); + return true; + } + + load(assetUri: string): Promise { + this.loadedUrls.push(assetUri); + return this.loadResult; + } + + destroy(): Promise { + this.destroyCount += 1; + return Promise.resolve(); + } + + addEventListener(type: string, listener: Listener): void { + const set = this.listeners.get(type) ?? new Set(); + set.add(listener); + this.listeners.set(type, set); + } + + removeEventListener(type: string, listener: Listener): void { + this.listeners.get(type)?.delete(listener); + } + + dispatch(type: string, detail?: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener({ type, detail } as unknown as Event); + } + } + + getAudioTracks() { + return []; + } + selectAudioTrack(): void {} + getTextTracks() { + return []; + } + selectTextTrack(track: unknown): void { + this.selectTextTrackCalls.push(track); + } + isLive(): boolean { + return false; + } +} + +const flush = async (): Promise => { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } +}; + +describe('ShakaVideoSession', () => { + const video = {} as HTMLVideoElement; + let issues: PlaybackDiagnostic[]; + let installAll: jest.Mock; + let browserSupported: boolean; + let loadShaka: jest.Mock; + let fakeModule: ShakaModuleLike; + + const createSession = () => + new ShakaVideoSession({ + player: InlinePlaybackPlayer.Html5, + emitPlaybackIssue: (issue) => issues.push(issue), + showCaptions: () => false, + loadShaka, + }); + + beforeEach(() => { + FakeShakaPlayer.instances = []; + issues = []; + browserSupported = true; + installAll = jest.fn(); + fakeModule = { + Player: Object.assign( + function (this: unknown) { + return new FakeShakaPlayer(); + } as unknown as ShakaModuleLike['Player'], + { isBrowserSupported: () => browserSupported } + ), + polyfill: { installAll }, + }; + loadShaka = jest.fn().mockResolvedValue(fakeModule); + }); + + it('lazily loads the module once, installs polyfills and starts playback', async () => { + const session = createSession(); + session.start(video, 'http://example.com/a.mpd'); + await flush(); + session.start(video, 'http://example.com/b.mpd'); + await flush(); + + expect(loadShaka).toHaveBeenCalledTimes(1); + expect(installAll).toHaveBeenCalledTimes(1); + const [first, second] = FakeShakaPlayer.instances; + expect(first.attachedTo).toBe(video); + expect(first.loadedUrls).toEqual(['http://example.com/a.mpd']); + expect(first.destroyCount).toBe(1); + expect(second.loadedUrls).toEqual(['http://example.com/b.mpd']); + expect(issues).toEqual([]); + }); + + it('configures ClearKey keys before load for supported DRM', async () => { + const drm: ChannelDrm = { + licenseType: 'clearkey', + supported: true, + clearKeys: { abc: 'def' }, + }; + const session = createSession(); + session.start(video, 'http://example.com/enc.mpd', drm); + await flush(); + + const player = FakeShakaPlayer.instances[0]; + expect(player.configureCalls).toEqual([ + { drm: { clearKeys: { abc: 'def' } } }, + ]); + expect(player.loadedUrls).toEqual(['http://example.com/enc.mpd']); + }); + + it('starts without DRM config for clear DASH channels', async () => { + const session = createSession(); + session.start(video, 'http://example.com/clear.mpd'); + await flush(); + + expect(FakeShakaPlayer.instances[0].configureCalls).toEqual([]); + expect(issues).toEqual([]); + }); + + it('emits a DRM diagnostic and starts no engine for unsupported license types', async () => { + const session = createSession(); + session.start(video, 'http://example.com/wv.mpd', { + licenseType: 'com.widevine.alpha', + supported: false, + }); + await flush(); + + expect(loadShaka).not.toHaveBeenCalled(); + expect(FakeShakaPlayer.instances).toHaveLength(0); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + expect(issues[0].source).toBe(PlaybackDiagnosticSource.Shaka); + expect(issues[0].details).toContain('com.widevine.alpha'); + }); + + it('classifies critical shaka error events and ignores recoverable ones', async () => { + const session = createSession(); + session.start(video, 'http://example.com/a.mpd'); + await flush(); + + const player = FakeShakaPlayer.instances[0]; + player.dispatch('error', { severity: 1, category: 1, code: 1002 }); + expect(issues).toEqual([]); + + player.dispatch('error', { severity: 2, category: 6, code: 6001 }); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); + }); + + it('emits a classified diagnostic when load rejects with a shaka error', async () => { + const loadError = { severity: 2, category: 4, code: 4001 }; + loadShaka.mockResolvedValue({ + ...fakeModule, + Player: Object.assign( + function (this: unknown) { + const player = new FakeShakaPlayer(); + player.loadResult = Promise.reject(loadError); + return player; + } as unknown as ShakaModuleLike['Player'], + { isBrowserSupported: () => true } + ), + }); + + const session = createSession(); + session.start(video, 'http://example.com/bad.mpd'); + await flush(); + + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe( + PlaybackDiagnosticCode.UnsupportedContainer + ); + expect(issues[0].sourceUrl).toBe('http://example.com/bad.mpd'); + }); + + it('suppresses interrupted loads and stale results after a channel switch', async () => { + let resolveLoad: () => void = () => undefined; + loadShaka.mockResolvedValue({ + ...fakeModule, + Player: Object.assign( + function (this: unknown) { + const player = new FakeShakaPlayer(); + if (FakeShakaPlayer.instances.length === 1) { + player.loadResult = new Promise((resolve) => { + resolveLoad = () => resolve(undefined); + }); + } + return player; + } as unknown as ShakaModuleLike['Player'], + { isBrowserSupported: () => true } + ), + }); + + const session = createSession(); + session.start(video, 'http://example.com/slow.mpd'); + await flush(); + session.start(video, 'http://example.com/fast.mpd'); + resolveLoad(); + await flush(); + + const [slow, fast] = FakeShakaPlayer.instances; + expect(slow.destroyCount).toBe(1); + expect(fast.loadedUrls).toEqual(['http://example.com/fast.mpd']); + // The slow player resolved after being superseded; its post-load + // caption suppression must not have been applied. + expect(slow.selectTextTrackCalls).toEqual([]); + expect(fast.selectTextTrackCalls).toEqual([null]); + expect(issues).toEqual([]); + }); + + it('emits an unsupported-container diagnostic when the browser lacks MSE/EME', async () => { + browserSupported = false; + const session = createSession(); + session.start(video, 'http://example.com/a.mpd'); + await flush(); + + expect(FakeShakaPlayer.instances).toHaveLength(0); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe( + PlaybackDiagnosticCode.UnsupportedContainer + ); + }); + + it('destroy tears down the engine and blocks later starts', async () => { + const session = createSession(); + session.start(video, 'http://example.com/a.mpd'); + await flush(); + session.destroy(); + await flush(); + session.start(video, 'http://example.com/b.mpd'); + await flush(); + + expect(FakeShakaPlayer.instances).toHaveLength(1); + expect(FakeShakaPlayer.instances[0].destroyCount).toBe(1); + }); +}); 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 new file mode 100644 index 000000000..8d475da31 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -0,0 +1,338 @@ +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; +import type { + InlinePlaybackPlayer, + PlaybackDiagnostic, + PlaybackSourceMetadata, +} from '../playback-diagnostics/playback-diagnostics.model'; +import { PlaybackDiagnosticCode as DiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model'; +import { PlaybackDiagnosticSource as DiagnosticSource } from '../playback-diagnostics/playback-diagnostics.model'; +import { + createPlaybackDiagnostic, + createPlaybackSourceMetadata, +} from '../playback-diagnostics/playback-diagnostics.util'; +import { + classifyShakaPlaybackIssue, + createUnsupportedDrmDiagnostic, +} from './shaka-error-classifier'; +import { + loadShakaModule, + type ShakaErrorLike, + type ShakaModuleLike, + type ShakaModuleLoader, + type ShakaPlayerLike, +} from './shaka-module.types'; + +export interface ShakaVideoSessionConfig { + player: InlinePlaybackPlayer; + emitPlaybackIssue: (issue: PlaybackDiagnostic) => void; + showCaptions?: () => boolean; + loadShaka?: ShakaModuleLoader; +} + +const DASH_MIME_TYPE = 'application/dash+xml'; +/** `shaka.util.Error.Severity.RECOVERABLE` — Shaka retries these itself. */ +const SHAKA_SEVERITY_RECOVERABLE = 1; +/** `shaka.util.Error.Code.LOAD_INTERRUPTED` — expected when a newer + * `load()`/`destroy()` supersedes an in-flight one. */ +const SHAKA_LOAD_INTERRUPTED = 7000; + +const PLAYER_REFRESH_EVENTS = [ + 'loaded', + 'trackschanged', + 'adaptation', + 'variantchanged', + 'textchanged', + 'texttrackvisibility', +] as const; + +/** + * Owns one Shaka Player engine for DASH (`.mpd`) playback, mirroring how + * hls.js/mpegts.js engines are owned by their player components. + * + * - `shaka-player` is loaded lazily on the first start; the module is cached. + * - `start()`/`stop()` are synchronous entry points; the async work is + * serialized on an internal chain and guarded by a generation counter, so a + * channel switch during an in-flight load can never resurrect a stale + * engine. + * - ClearKey DRM comes from the channel's {@link ChannelDrm}; unsupported + * license types emit a DRM diagnostic instead of starting an engine. + */ +export class ShakaVideoSession { + private module: ShakaModuleLike | null = null; + private player: ShakaPlayerLike | null = null; + private playerErrorListener: ((event: Event) => void) | null = null; + private playerRefreshListener: (() => void) | null = null; + private readonly refreshListeners = new Set<() => void>(); + private operationChain: Promise = Promise.resolve(); + private generation = 0; + private destroyed = false; + + constructor(private readonly config: ShakaVideoSessionConfig) {} + + /** Starts DASH playback of `url` on `video`. Synchronous entry point. */ + start(video: HTMLVideoElement, url: string, drm?: ChannelDrm): void { + if (this.destroyed) { + return; + } + + if (drm && !drm.supported) { + this.stop(); + this.config.emitPlaybackIssue( + createUnsupportedDrmDiagnostic( + drm.licenseType, + this.createMetadata(url) + ) + ); + return; + } + + const generation = ++this.generation; + this.enqueue(async () => { + await this.destroyPlayer(); + if (this.isStale(generation)) { + return; + } + await this.startPlayback(generation, video, url, drm); + }); + } + + /** Tears down the current engine (if any) without ending the session. */ + stop(): void { + this.generation += 1; + this.enqueue(() => this.destroyPlayer()); + } + + destroy(): void { + if (this.destroyed) { + return; + } + + this.destroyed = true; + this.refreshListeners.clear(); + this.stop(); + } + + /** Current engine, for the shared-controls track bridge. */ + getPlayer(): ShakaPlayerLike | null { + return this.player; + } + + /** Registers a callback fired whenever tracks/text state may have changed. */ + subscribe(listener: () => void): () => void { + this.refreshListeners.add(listener); + return () => this.refreshListeners.delete(listener); + } + + private async startPlayback( + generation: number, + video: HTMLVideoElement, + url: string, + drm: ChannelDrm | undefined + ): Promise { + let module: ShakaModuleLike; + try { + module = await this.loadModule(); + } catch (error: unknown) { + this.emitIfCurrent( + generation, + classifyShakaPlaybackIssue( + { message: toErrorMessage(error) }, + this.createMetadata(url) + ) + ); + return; + } + + if (this.isStale(generation)) { + return; + } + + if (!module.Player.isBrowserSupported()) { + this.emitIfCurrent( + generation, + createPlaybackDiagnostic({ + code: DiagnosticCode.UnsupportedContainer, + source: DiagnosticSource.Shaka, + metadata: this.createMetadata(url), + details: 'Shaka Player is not supported in this browser', + }) + ); + return; + } + + const player = new module.Player(); + this.player = player; + this.bindPlayerListeners(player, generation, url); + + if (drm?.clearKeys) { + player.configure({ drm: { clearKeys: drm.clearKeys } }); + } + + try { + await player.attach(video); + await player.load(url); + } catch (error: unknown) { + this.handleLoadFailure(generation, player, url, error); + return; + } + + if (this.isStale(generation)) { + return; + } + + // Shaka 5 shows a text track by selecting it; keep captions off when + // the preference is disabled in case the manifest auto-selected one. + if (!(this.config.showCaptions?.() ?? false)) { + player.selectTextTrack(null); + } + this.notifyRefresh(); + } + + private handleLoadFailure( + generation: number, + player: ShakaPlayerLike, + url: string, + error: unknown + ): void { + const shakaError = asShakaError(error); + if ( + this.isStale(generation) || + this.player !== player || + shakaError?.code === SHAKA_LOAD_INTERRUPTED + ) { + return; + } + + this.config.emitPlaybackIssue( + classifyShakaPlaybackIssue( + shakaError ?? { message: toErrorMessage(error) }, + this.createMetadata(url) + ) + ); + } + + private bindPlayerListeners( + player: ShakaPlayerLike, + generation: number, + url: string + ): void { + const errorListener = (event: Event): void => { + if (this.isStale(generation) || this.player !== player) { + return; + } + + const detail = (event as { detail?: Partial }) + .detail; + if (detail?.severity === SHAKA_SEVERITY_RECOVERABLE) { + return; + } + + this.config.emitPlaybackIssue( + classifyShakaPlaybackIssue(detail, this.createMetadata(url)) + ); + }; + const refreshListener = (): void => { + if (!this.isStale(generation) && this.player === player) { + this.notifyRefresh(); + } + }; + + this.playerErrorListener = errorListener; + this.playerRefreshListener = refreshListener; + player.addEventListener('error', errorListener); + for (const event of PLAYER_REFRESH_EVENTS) { + player.addEventListener(event, refreshListener); + } + } + + private async destroyPlayer(): Promise { + const player = this.player; + this.player = null; + if (!player) { + this.playerErrorListener = null; + this.playerRefreshListener = null; + return; + } + + if (this.playerErrorListener) { + player.removeEventListener('error', this.playerErrorListener); + } + if (this.playerRefreshListener) { + for (const event of PLAYER_REFRESH_EVENTS) { + player.removeEventListener(event, this.playerRefreshListener); + } + } + this.playerErrorListener = null; + this.playerRefreshListener = null; + this.notifyRefresh(); + + try { + await player.destroy(); + } catch { + // Destroy failures leave nothing actionable for the session. + } + } + + private async loadModule(): Promise { + if (this.module) { + return this.module; + } + + const loader = this.config.loadShaka ?? loadShakaModule; + const module = await loader(); + module.polyfill.installAll(); + this.module = module; + return module; + } + + private createMetadata(url: string): PlaybackSourceMetadata { + return createPlaybackSourceMetadata({ + url, + mimeType: DASH_MIME_TYPE, + player: this.config.player, + }); + } + + private enqueue(operation: () => Promise): void { + this.operationChain = this.operationChain.then(operation, operation); + } + + private isStale(generation: number): boolean { + return this.destroyed || generation !== this.generation; + } + + private emitIfCurrent( + generation: number, + issue: PlaybackDiagnostic + ): void { + if (!this.isStale(generation)) { + this.config.emitPlaybackIssue(issue); + } + } + + private notifyRefresh(): void { + for (const listener of [...this.refreshListeners]) { + listener(); + } + } +} + +function asShakaError(error: unknown): Partial | null { + if (!error || typeof error !== 'object') { + return null; + } + + const candidate = error as Partial; + return typeof candidate.code === 'number' || + typeof candidate.category === 'number' + ? candidate + : null; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return typeof error === 'string' ? error : String(error); +} 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 fbab90b9e..91a6bd1a5 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 @@ -225,6 +225,7 @@ export class WebPlayerViewComponent { '', }, radio: 'false', + drm: playback.drm, }; } @@ -391,6 +392,8 @@ export class WebPlayerViewComponent { return 'mpegts.js'; case 'native': return 'Native media element'; + case 'shaka': + return 'Shaka Player'; case 'source': return 'Stream metadata'; default: diff --git a/libs/ui/playback/src/lib/web-video-support/exit-owned-fullscreen.util.ts b/libs/ui/playback/src/lib/web-video-support/exit-owned-fullscreen.util.ts new file mode 100644 index 000000000..52d4ba2cd --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/exit-owned-fullscreen.util.ts @@ -0,0 +1,23 @@ +/** + * Exits document fullscreen only when the given surface owns it. Shared by + * player components whose shared-controls mode uses DOM-based fullscreen. + */ +export function exitOwnedFullscreen( + sharedControls: boolean, + surface: HTMLElement | undefined, + reportError: (error: unknown) => void +): void { + if ( + !sharedControls || + document.fullscreenElement !== surface || + typeof document.exitFullscreen !== 'function' + ) { + return; + } + + try { + void Promise.resolve(document.exitFullscreen()).catch(reportError); + } catch (error: unknown) { + reportError(error); + } +} diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-native-source.util.ts b/libs/ui/playback/src/lib/web-video-support/web-video-native-source.util.ts new file mode 100644 index 000000000..df2c31d16 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/web-video-native-source.util.ts @@ -0,0 +1,22 @@ +/** Helpers for driving the native `