fix(playback): interrupt stalled Shaka loads and destroy failed engines

Two review findings on the ShakaVideoSession lifecycle:

- stop()/start() now tear the current player down immediately instead of
  queueing the destroy behind the in-flight operation. Shaka's destroy()
  interrupts a pending load() (LOAD_INTERRUPTED), so a stalled manifest
  fetch can no longer wedge the operation chain and block the next
  channel start (Codex P1).
- A rejected attach()/load() now destroys the failed player after
  emitting the diagnostic, so a non-functional engine never stays
  attached to the media element or exposed to the shared-controls
  bridge (Greptile P1).

Regression tests cover both paths. The Shaka fakes are consolidated into
a shared jest-free test double that mirrors the destroy-interrupts-load
semantic, and the ArtPlayer source-session spec is split (fixtures +
DASH cases) to stay within the max-lines lint budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-23 23:40:40 +02:00
1 parent 2b3d7fa1a9
commit 48d191b0d7
6 files changed
+557 -474

No files matched your search

@@ -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);
});
});
@@ -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<string, Array<(...args: unknown[]) => 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<string, (...args: unknown[]) => 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> | 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<void> {
({ 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],
};
}
@@ -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<string, Array<(...args: unknown[]) => 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<string, (...args: unknown[]) => 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> | 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<void> {
for (let index = 0; index < 10; index += 1) {
await Promise.resolve();
}
}
interface FakeShakaPlayerInstance {
attachedTo: HTMLMediaElement | null;
loadedUrls: string[];
configureCalls: Record<string, unknown>[];
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<string, unknown>[] = [];
destroyCount = 0;
constructor() {
instances.push(this);
}
attach(mediaElement: HTMLMediaElement): Promise<unknown> {
this.attachedTo = mediaElement;
return Promise.resolve();
}
configure(config: Record<string, unknown>): boolean {
this.configureCalls.push(config);
return true;
}
load(assetUri: string): Promise<unknown> {
this.loadedUrls.push(assetUri);
return Promise.resolve();
}
destroy(): Promise<unknown> {
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],
};
}
@@ -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<string, unknown>[] = [];
readonly selectTextTrackCalls: unknown[] = [];
readonly selectedAudioTracks: ShakaAudioTrackLike[] = [];
readonly listeners = new Map<string, Set<Listener>>();
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<unknown> = Promise.resolve();
private pendingLoadReject: ((error: unknown) => void) | null = null;
attach(mediaElement: HTMLMediaElement): Promise<unknown> {
this.attachedTo = mediaElement;
return Promise.resolve();
}
configure(config: Record<string, unknown>): boolean {
this.configureCalls.push(config);
return true;
}
load(assetUri: string): Promise<unknown> {
this.loadedUrls.push(assetUri);
if (this.stallNextLoad) {
this.stallNextLoad = false;
return new Promise((_resolve, reject) => {
this.pendingLoadReject = reject;
});
}
return this.loadResult;
}
destroy(): Promise<unknown> {
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<Listener>();
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<void> {
for (let index = 0; index < 10; index += 1) {
await Promise.resolve();
}
}
@@ -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<string, unknown>[] = [];
readonly listeners = new Map<string, Set<Listener>>();
readonly selectTextTrackCalls: unknown[] = [];
attachedTo: HTMLMediaElement | null = null;
loadedUrls: string[] = [];
destroyCount = 0;
loadResult: Promise<unknown> = Promise.resolve();
constructor() {
FakeShakaPlayer.instances.push(this);
}
attach(mediaElement: HTMLMediaElement): Promise<unknown> {
this.attachedTo = mediaElement;
return Promise.resolve();
}
configure(config: Record<string, unknown>): boolean {
this.configureCalls.push(config);
return true;
}
load(assetUri: string): Promise<unknown> {
this.loadedUrls.push(assetUri);
return this.loadResult;
}
destroy(): Promise<unknown> {
this.destroyCount += 1;
return Promise.resolve();
}
addEventListener(type: string, listener: Listener): void {
const set = this.listeners.get(type) ?? new Set<Listener>();
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<void> => {
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<typeof createFakeShakaEnvironment>
) =>
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<unknown>((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<unknown>((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);
});
});
@@ -64,6 +64,7 @@ export class ShakaVideoSession {
private playerRefreshListener: (() => void) | null = null;
private readonly refreshListeners = new Set<() => void>();
private operationChain: Promise<void> = Promise.resolve();
private pendingTeardown: Promise<void> = 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<void> {
/**
* 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<ShakaModuleLike> {