mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
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:
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> {
|
||||
|
||||
Reference in new issue
Block a user