diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts new file mode 100644 index 000000000..dd48c9565 --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts @@ -0,0 +1,85 @@ +import type { ChannelDrm } from '@iptvnator/shared/interfaces'; +import { + createFakeShakaEnvironment, + flushShakaMicrotasks, +} from '../shaka-engine/shaka-player-test-double'; +import { + createSession, + hlsInstances, + initArtPlayerSourceSessionModule, + mpegTsInstances, + resetArtPlayerSourceFixtures, +} from './art-player-source-session.spec-fixtures'; + +describe('ArtPlayerSourceSession DASH (mpd custom type)', () => { + beforeAll(async () => { + await initArtPlayerSourceSessionModule(); + }); + + beforeEach(() => { + resetArtPlayerSourceFixtures(); + }); + + it('starts the Shaka engine with channel DRM for the mpd custom type', async () => { + const fakeShaka = createFakeShakaEnvironment(); + 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 flushShakaMicrotasks(); + + expect(fakeShaka.loaderCalls).toBe(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 = createFakeShakaEnvironment(); + const { session, player, video } = createSession({ + sharedControls: false, + loadShaka: fakeShaka.loader, + }); + session.attach(player); + + session.customType['mpd']?.( + video, + 'https://example.test/live.mpd', + player + ); + await flushShakaMicrotasks(); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + await flushShakaMicrotasks(); + + expect(fakeShaka.instances[0].destroyCount).toBe(1); + expect(hlsInstances).toHaveLength(1); + + session.destroy(); + await flushShakaMicrotasks(); + expect(fakeShaka.instances).toHaveLength(1); + }); +}); diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts new file mode 100644 index 000000000..26a046c2c --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts @@ -0,0 +1,191 @@ +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 { ShakaModuleLoader } from '../shaka-engine/shaka-module.types'; +import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session'; + +/** + * Shared engine mocks and session bootstrap for the ArtPlayerSourceSession + * spec files. Importing this module registers the hls.js/mpegts.js module + * mocks, so it must be imported before `initArtPlayerSourceSessionModule()` + * dynamically loads the session under test. + */ + +export const hlsInstances: MockHls[] = []; +export const mpegTsInstances: MockMpegTsPlayer[] = []; + +export class MockHls { + static Events = { + MANIFEST_PARSED: 'manifestParsed', + ERROR: 'error', + AUDIO_TRACKS_UPDATED: 'audioTracksUpdated', + AUDIO_TRACK_SWITCHING: 'audioTrackSwitching', + AUDIO_TRACK_SWITCHED: 'audioTrackSwitched', + SUBTITLE_TRACKS_UPDATED: 'subtitleTracksUpdated', + SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared', + SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch', + MANIFEST_LOADING: 'manifestLoading', + }; + + static isSupported = jest.fn(() => true); + + readonly handlers = new Map void>>(); + readonly on = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + const handlers = this.handlers.get(event) ?? []; + handlers.push(handler); + this.handlers.set(event, handlers); + } + ); + readonly off = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + const handlers = this.handlers.get(event) ?? []; + this.handlers.set( + event, + handlers.filter((candidate) => candidate !== handler) + ); + } + ); + readonly attachMedia = jest.fn(); + readonly loadSource = jest.fn(); + readonly destroy = jest.fn(); + audioTracks: Array<{ name?: string; lang?: string }> = []; + audioTrack = 0; + subtitleTracks: Array<{ name?: string; lang?: string }> = []; + subtitleTrack = -1; + subtitleDisplay = false; + + constructor() { + hlsInstances.push(this); + } + + emit(event: string, ...args: unknown[]): void { + for (const handler of this.handlers.get(event) ?? []) { + handler(...args); + } + } +} + +export class MockMpegTsPlayer { + readonly handlers = new Map void>(); + readonly attachMediaElement = jest.fn(); + readonly on = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + this.handlers.set(event, handler); + } + ); + readonly off = jest.fn( + (event: string, handler: (...args: unknown[]) => void) => { + if (this.handlers.get(event) === handler) { + this.handlers.delete(event); + } + } + ); + readonly load = jest.fn(); + readonly play = jest.fn(() => undefined as Promise | void); + readonly pause = jest.fn(); + readonly unload = jest.fn(); + readonly detachMediaElement = jest.fn(); + readonly destroy = jest.fn(); + + constructor() { + mpegTsInstances.push(this); + } +} + +export const createMpegTsPlayer = jest.fn(() => new MockMpegTsPlayer()); + +jest.unstable_mockModule('hls.js', () => ({ + default: MockHls, +})); + +jest.unstable_mockModule('mpegts.js', () => ({ + default: { + Events: { ERROR: 'error' }, + createPlayer: createMpegTsPlayer, + isSupported: jest.fn(() => true), + }, +})); + +let sessionConstructor: + | typeof import('./art-player-source-session').ArtPlayerSourceSession + | undefined; + +/** Loads the session module after the engine mocks above are registered. */ +export async function initArtPlayerSourceSessionModule(): Promise { + ({ ArtPlayerSourceSession: sessionConstructor } = await import( + './art-player-source-session' + )); +} + +export function resetArtPlayerSourceFixtures(): void { + hlsInstances.length = 0; + mpegTsInstances.length = 0; + createMpegTsPlayer + .mockClear() + .mockImplementation(() => new MockMpegTsPlayer()); + MockHls.isSupported.mockReturnValue(true); +} + +export 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; + video: HTMLVideoElement; + adapter: WebVideoControlsAdapter; + settingAdd: jest.Mock; +} { + const video = document.createElement('video'); + const settingAdd = jest.fn(); + const player = { + video, + setting: { add: settingAdd }, + } as unknown as Artplayer; + const adapter = new WebVideoControlsAdapter(); + const Session = requireSessionConstructor(); + + return { + session: new Session({ + sharedControls, + controlsAdapter: adapter, + isLive: () => isLive, + showCaptions: () => false, + emitPlaybackIssue, + getDrm, + loadShaka, + }), + player, + video, + adapter, + settingAdd, + }; +} + +function requireSessionConstructor(): typeof import('./art-player-source-session').ArtPlayerSourceSession { + if (!sessionConstructor) { + throw new Error( + 'ArtPlayerSourceSession test module is not initialized' + ); + } + return sessionConstructor; +} + +export function createTimeRanges(ends: number[]): TimeRanges { + return { + length: ends.length, + start: () => 0, + end: (index: number) => ends[index], + }; +} 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 d390bb954..05b7f031f 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,123 +1,23 @@ -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[] = []; -const mpegTsInstances: MockMpegTsPlayer[] = []; - -class MockHls { - static Events = { - MANIFEST_PARSED: 'manifestParsed', - ERROR: 'error', - AUDIO_TRACKS_UPDATED: 'audioTracksUpdated', - AUDIO_TRACK_SWITCHING: 'audioTrackSwitching', - AUDIO_TRACK_SWITCHED: 'audioTrackSwitched', - SUBTITLE_TRACKS_UPDATED: 'subtitleTracksUpdated', - SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared', - SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch', - MANIFEST_LOADING: 'manifestLoading', - }; - - static isSupported = jest.fn(() => true); - - readonly handlers = new Map void>>(); - readonly on = jest.fn( - (event: string, handler: (...args: unknown[]) => void) => { - const handlers = this.handlers.get(event) ?? []; - handlers.push(handler); - this.handlers.set(event, handlers); - } - ); - readonly off = jest.fn( - (event: string, handler: (...args: unknown[]) => void) => { - const handlers = this.handlers.get(event) ?? []; - this.handlers.set( - event, - handlers.filter((candidate) => candidate !== handler) - ); - } - ); - readonly attachMedia = jest.fn(); - readonly loadSource = jest.fn(); - readonly destroy = jest.fn(); - audioTracks: Array<{ name?: string; lang?: string }> = []; - audioTrack = 0; - subtitleTracks: Array<{ name?: string; lang?: string }> = []; - subtitleTrack = -1; - subtitleDisplay = false; - - constructor() { - hlsInstances.push(this); - } - - emit(event: string, ...args: unknown[]): void { - for (const handler of this.handlers.get(event) ?? []) { - handler(...args); - } - } -} - -class MockMpegTsPlayer { - readonly handlers = new Map void>(); - readonly attachMediaElement = jest.fn(); - readonly on = jest.fn( - (event: string, handler: (...args: unknown[]) => void) => { - this.handlers.set(event, handler); - } - ); - readonly off = jest.fn( - (event: string, handler: (...args: unknown[]) => void) => { - if (this.handlers.get(event) === handler) { - this.handlers.delete(event); - } - } - ); - readonly load = jest.fn(); - readonly play = jest.fn(() => undefined as Promise | void); - readonly pause = jest.fn(); - readonly unload = jest.fn(); - readonly detachMediaElement = jest.fn(); - readonly destroy = jest.fn(); - - constructor() { - mpegTsInstances.push(this); - } -} - -const createMpegTsPlayer = jest.fn(() => new MockMpegTsPlayer()); - -jest.unstable_mockModule('hls.js', () => ({ - default: MockHls, -})); - -jest.unstable_mockModule('mpegts.js', () => ({ - default: { - Events: { ERROR: 'error' }, - createPlayer: createMpegTsPlayer, - isSupported: jest.fn(() => true), - }, -})); +import { + MockHls, + MockMpegTsPlayer, + createMpegTsPlayer, + createSession, + createTimeRanges, + hlsInstances, + initArtPlayerSourceSessionModule, + mpegTsInstances, + resetArtPlayerSourceFixtures, +} from './art-player-source-session.spec-fixtures'; describe('ArtPlayerSourceSession', () => { - let ArtPlayerSourceSession: typeof import('./art-player-source-session').ArtPlayerSourceSession; - beforeAll(async () => { - ({ ArtPlayerSourceSession } = - await import('./art-player-source-session')); - sessionConstructor = ArtPlayerSourceSession; + await initArtPlayerSourceSessionModule(); }); beforeEach(() => { - hlsInstances.length = 0; - mpegTsInstances.length = 0; - createMpegTsPlayer.mockClear(); - MockHls.isSupported.mockReturnValue(true); + resetArtPlayerSourceFixtures(); }); it('binds HLS tracks to shared controls and tears every listener down exactly', () => { @@ -340,209 +240,4 @@ 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; - video: HTMLVideoElement; - adapter: WebVideoControlsAdapter; - settingAdd: jest.Mock; -} { - const video = document.createElement('video'); - const settingAdd = jest.fn(); - const player = { - video, - setting: { add: settingAdd }, - } as unknown as Artplayer; - const adapter = new WebVideoControlsAdapter(); - const Session = requireSessionConstructor(); - - return { - session: new Session({ - sharedControls, - controlsAdapter: adapter, - isLive: () => isLive, - showCaptions: () => false, - emitPlaybackIssue, - getDrm, - loadShaka, - }), - player, - video, - adapter, - settingAdd, - }; -} - -let sessionConstructor: - | typeof import('./art-player-source-session').ArtPlayerSourceSession - | undefined; - -function requireSessionConstructor(): typeof import('./art-player-source-session').ArtPlayerSourceSession { - if (!sessionConstructor) { - throw new Error( - 'ArtPlayerSourceSession test module is not initialized' - ); - } - return sessionConstructor; -} - -function createTimeRanges(ends: number[]): TimeRanges { - return { - length: ends.length, - start: () => 0, - end: (index: number) => ends[index], - }; -} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts new file mode 100644 index 000000000..86a64b679 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts @@ -0,0 +1,161 @@ +import type { + ShakaAudioTrackLike, + ShakaModuleLike, + ShakaModuleLoader, + ShakaPlayerLike, + ShakaTextTrackLike, +} from './shaka-module.types'; + +/** + * Jest-free Shaka test double shared by the shaka-engine and ArtPlayer specs. + * Mirrors the {@link ShakaPlayerLike} surface, including the real Shaka + * semantic that `destroy()` interrupts an in-flight `load()` with + * `LOAD_INTERRUPTED` (7000). + */ + +type Listener = (event: Event) => void; + +const LOAD_INTERRUPTED_ERROR = { + severity: 2, + category: 7, + code: 7000, +} as const; + +export class FakeShakaPlayer implements ShakaPlayerLike { + readonly configureCalls: Record[] = []; + readonly selectTextTrackCalls: unknown[] = []; + readonly selectedAudioTracks: ShakaAudioTrackLike[] = []; + readonly listeners = new Map>(); + attachedTo: HTMLMediaElement | null = null; + loadedUrls: string[] = []; + destroyCount = 0; + audioTracks: ShakaAudioTrackLike[] = []; + textTracks: ShakaTextTrackLike[] = []; + /** When true, the next `load()` stays pending until `destroy()`. */ + stallNextLoad = false; + loadResult: Promise = Promise.resolve(); + private pendingLoadReject: ((error: unknown) => void) | null = null; + + 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); + if (this.stallNextLoad) { + this.stallNextLoad = false; + return new Promise((_resolve, reject) => { + this.pendingLoadReject = reject; + }); + } + return this.loadResult; + } + + destroy(): Promise { + this.destroyCount += 1; + this.pendingLoadReject?.(LOAD_INTERRUPTED_ERROR); + this.pendingLoadReject = null; + 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(): ShakaAudioTrackLike[] { + return this.audioTracks; + } + + selectAudioTrack(track: ShakaAudioTrackLike): void { + this.selectedAudioTracks.push(track); + } + + getTextTracks(): ShakaTextTrackLike[] { + return this.textTracks; + } + + selectTextTrack(track: ShakaTextTrackLike | null): void { + this.selectTextTrackCalls.push(track); + } + + isLive(): boolean { + return false; + } +} + +export interface FakeShakaEnvironment { + /** Every player the fake module has constructed, in creation order. */ + readonly instances: FakeShakaPlayer[]; + readonly loader: ShakaModuleLoader; + readonly module: ShakaModuleLike; + loaderCalls: number; + installAllCalls: number; + browserSupported: boolean; +} + +export function createFakeShakaEnvironment(options?: { + onCreate?: (player: FakeShakaPlayer, index: number) => void; +}): FakeShakaEnvironment { + const instances: FakeShakaPlayer[] = []; + + const environment: FakeShakaEnvironment = { + instances, + loaderCalls: 0, + installAllCalls: 0, + browserSupported: true, + module: undefined as unknown as ShakaModuleLike, + loader: undefined as unknown as ShakaModuleLoader, + }; + + const playerConstructor = function (this: unknown) { + const player = new FakeShakaPlayer(); + options?.onCreate?.(player, instances.length); + instances.push(player); + return player; + } as unknown as ShakaModuleLike['Player']; + ( + playerConstructor as unknown as { isBrowserSupported: () => boolean } + ).isBrowserSupported = () => environment.browserSupported; + + const module: ShakaModuleLike = { + Player: playerConstructor, + polyfill: { + installAll: () => { + environment.installAllCalls += 1; + }, + }, + }; + + (environment as { module: ShakaModuleLike }).module = module; + (environment as { loader: ShakaModuleLoader }).loader = () => { + environment.loaderCalls += 1; + return Promise.resolve(module); + }; + + return environment; +} + +/** Drains chained microtasks so queued session operations settle. */ +export async function flushShakaMicrotasks(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } +} 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 986b394f8..d2b2a5b29 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 @@ -5,128 +5,42 @@ import { PlaybackDiagnosticCode, PlaybackDiagnosticSource, } from '../playback-diagnostics/playback-diagnostics.model'; -import type { - ShakaModuleLike, - ShakaPlayerLike, -} from './shaka-module.types'; +import { + FakeShakaPlayer, + createFakeShakaEnvironment, + flushShakaMicrotasks as flush, +} from './shaka-player-test-double'; 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 = () => + const createSession = ( + environment: ReturnType + ) => new ShakaVideoSession({ player: InlinePlaybackPlayer.Html5, emitPlaybackIssue: (issue) => issues.push(issue), showCaptions: () => false, - loadShaka, + loadShaka: environment.loader, }); 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(); + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); 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(environment.loaderCalls).toBe(1); + expect(environment.installAllCalls).toBe(1); + const [first, second] = environment.instances; expect(first.attachedTo).toBe(video); expect(first.loadedUrls).toEqual(['http://example.com/a.mpd']); expect(first.destroyCount).toBe(1); @@ -135,16 +49,17 @@ describe('ShakaVideoSession', () => { }); it('configures ClearKey keys before load for supported DRM', async () => { + const environment = createFakeShakaEnvironment(); const drm: ChannelDrm = { licenseType: 'clearkey', supported: true, clearKeys: { abc: 'def' }, }; - const session = createSession(); + const session = createSession(environment); session.start(video, 'http://example.com/enc.mpd', drm); await flush(); - const player = FakeShakaPlayer.instances[0]; + const player = environment.instances[0]; expect(player.configureCalls).toEqual([ { drm: { clearKeys: { abc: 'def' } } }, ]); @@ -152,24 +67,26 @@ describe('ShakaVideoSession', () => { }); it('starts without DRM config for clear DASH channels', async () => { - const session = createSession(); + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); session.start(video, 'http://example.com/clear.mpd'); await flush(); - expect(FakeShakaPlayer.instances[0].configureCalls).toEqual([]); + expect(environment.instances[0].configureCalls).toEqual([]); expect(issues).toEqual([]); }); it('emits a DRM diagnostic and starts no engine for unsupported license types', async () => { - const session = createSession(); + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); 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(environment.loaderCalls).toBe(0); + expect(environment.instances).toHaveLength(0); expect(issues).toHaveLength(1); expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); expect(issues[0].source).toBe(PlaybackDiagnosticSource.Shaka); @@ -177,11 +94,12 @@ describe('ShakaVideoSession', () => { }); it('classifies critical shaka error events and ignores recoverable ones', async () => { - const session = createSession(); + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); session.start(video, 'http://example.com/a.mpd'); await flush(); - const player = FakeShakaPlayer.instances[0]; + const player = environment.instances[0]; player.dispatch('error', { severity: 1, category: 1, code: 1002 }); expect(issues).toEqual([]); @@ -190,21 +108,14 @@ describe('ShakaVideoSession', () => { expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); }); - it('emits a classified diagnostic when load rejects with a shaka error', async () => { + it('emits a diagnostic and tears the engine down when load rejects', 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 environment = createFakeShakaEnvironment({ + onCreate: (player) => { + player.loadResult = Promise.reject(loadError); + }, }); - - const session = createSession(); + const session = createSession(environment); session.start(video, 'http://example.com/bad.mpd'); await flush(); @@ -213,34 +124,59 @@ describe('ShakaVideoSession', () => { PlaybackDiagnosticCode.UnsupportedContainer ); expect(issues[0].sourceUrl).toBe('http://example.com/bad.mpd'); + // The failed engine must not stay attached or exposed to controls. + expect(environment.instances[0].destroyCount).toBe(1); + expect(session.getPlayer()).toBeNull(); + }); + + it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => { + const environment = createFakeShakaEnvironment({ + onCreate: (player, index) => { + if (index === 0) { + player.stallNextLoad = true; + } + }, + }); + const session = createSession(environment); + session.start(video, 'http://example.com/stalled.mpd'); + await flush(); + expect(environment.instances[0].loadedUrls).toEqual([ + 'http://example.com/stalled.mpd', + ]); + + session.stop(); + session.start(video, 'http://example.com/next.mpd'); + await flush(); + + const [stalled, next] = environment.instances; + expect(stalled.destroyCount).toBe(1); + expect(next.loadedUrls).toEqual(['http://example.com/next.mpd']); + expect(session.getPlayer()).toBe(next); + expect(issues).toEqual([]); }); 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 } - ), + let releaseFirstLoad: () => void = () => undefined; + const environment = createFakeShakaEnvironment({ + onCreate: (player, index) => { + if (index === 0) { + player.loadResult = new Promise((resolve) => { + releaseFirstLoad = () => resolve(undefined); + }); + } + }, }); - - const session = createSession(); + const session = createSession(environment); session.start(video, 'http://example.com/slow.mpd'); await flush(); session.start(video, 'http://example.com/fast.mpd'); - resolveLoad(); + releaseFirstLoad(); await flush(); - const [slow, fast] = FakeShakaPlayer.instances; + const [slow, fast] = environment.instances as [ + FakeShakaPlayer, + FakeShakaPlayer, + ]; expect(slow.destroyCount).toBe(1); expect(fast.loadedUrls).toEqual(['http://example.com/fast.mpd']); // The slow player resolved after being superseded; its post-load @@ -251,12 +187,13 @@ describe('ShakaVideoSession', () => { }); it('emits an unsupported-container diagnostic when the browser lacks MSE/EME', async () => { - browserSupported = false; - const session = createSession(); + const environment = createFakeShakaEnvironment(); + environment.browserSupported = false; + const session = createSession(environment); session.start(video, 'http://example.com/a.mpd'); await flush(); - expect(FakeShakaPlayer.instances).toHaveLength(0); + expect(environment.instances).toHaveLength(0); expect(issues).toHaveLength(1); expect(issues[0].code).toBe( PlaybackDiagnosticCode.UnsupportedContainer @@ -264,7 +201,8 @@ describe('ShakaVideoSession', () => { }); it('destroy tears down the engine and blocks later starts', async () => { - const session = createSession(); + const environment = createFakeShakaEnvironment(); + const session = createSession(environment); session.start(video, 'http://example.com/a.mpd'); await flush(); session.destroy(); @@ -272,7 +210,7 @@ describe('ShakaVideoSession', () => { session.start(video, 'http://example.com/b.mpd'); await flush(); - expect(FakeShakaPlayer.instances).toHaveLength(1); - expect(FakeShakaPlayer.instances[0].destroyCount).toBe(1); + expect(environment.instances).toHaveLength(1); + expect(environment.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 index 8d475da31..82becdae4 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 @@ -64,6 +64,7 @@ export class ShakaVideoSession { private playerRefreshListener: (() => void) | null = null; private readonly refreshListeners = new Set<() => void>(); private operationChain: Promise = Promise.resolve(); + private pendingTeardown: Promise = Promise.resolve(); private generation = 0; private destroyed = false; @@ -87,8 +88,9 @@ export class ShakaVideoSession { } const generation = ++this.generation; + this.beginPlayerTeardown(); this.enqueue(async () => { - await this.destroyPlayer(); + await this.pendingTeardown; if (this.isStale(generation)) { return; } @@ -99,7 +101,7 @@ export class ShakaVideoSession { /** Tears down the current engine (if any) without ending the session. */ stop(): void { this.generation += 1; - this.enqueue(() => this.destroyPlayer()); + this.beginPlayerTeardown(); } destroy(): void { @@ -108,8 +110,9 @@ export class ShakaVideoSession { } this.destroyed = true; + this.generation += 1; this.refreshListeners.clear(); - this.stop(); + this.beginPlayerTeardown(); } /** Current engine, for the shared-controls track bridge. */ @@ -209,6 +212,9 @@ export class ShakaVideoSession { this.createMetadata(url) ) ); + // Never leave a non-functional engine attached to the media element + // or exposed to the shared-controls bridge. + this.beginPlayerTeardown(); } private bindPlayerListeners( @@ -245,7 +251,14 @@ export class ShakaVideoSession { } } - private async destroyPlayer(): Promise { + /** + * Detaches and destroys the current engine immediately — never queued + * behind pending operations. `player.destroy()` interrupts an in-flight + * `load()` (it rejects with `LOAD_INTERRUPTED`), so a stalled manifest + * fetch cannot wedge the operation chain. New starts await + * {@link pendingTeardown} before attaching to the media element. + */ + private beginPlayerTeardown(): void { const player = this.player; this.player = null; if (!player) { @@ -266,11 +279,11 @@ export class ShakaVideoSession { this.playerRefreshListener = null; this.notifyRefresh(); - try { - await player.destroy(); - } catch { - // Destroy failures leave nothing actionable for the session. - } + const teardown = player.destroy().then( + () => undefined, + () => undefined + ); + this.pendingTeardown = this.pendingTeardown.then(() => teardown); } private async loadModule(): Promise {