mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
feat(playback): add Shaka DASH source engine with ClearKey support
Introduces ShakaVideoSession (libs/ui/playback/src/lib/shaka-engine/): a lazily imported shaka-player engine (separate lazy chunk, ~217 KB transfer) owning attach/configure/load with an operation queue and generation guard against channel-switch races. Channel ClearKey config maps to drm.clearKeys; channels with an unsupported license type emit a DrmOrEncryption diagnostic without starting an engine. Shaka errors are classified into the existing playback diagnostics (PlaybackDiagnosticSource.Shaka). Wires the engine into both built-in players like hls.js/mpegts.js: - HTML5: extension === 'mpd' branch in playChannel(); hls/mpegts/native glue extracted to helpers to keep the component within the size budget - ArtPlayer: customType 'mpd' in ArtPlayerSourceSession (+ getDrm seam) - Shared controls: WebVideoControlsSource kind 'shaka' + WebVideoShakaControls using the Shaka 5 text model (selectTextTrack(null) hides subtitles; Player.setTextTrackVisibility no longer exists) Adds a CJS shaka-player jest stub (video.js precedent) for web specs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9d336ee7a
commit
b1e9aeaf7b
26 files changed
+1775
-137
No files matched your search
@@ -39,6 +39,7 @@ export default {
|
||||
tslib: 'tslib/tslib.es6.js',
|
||||
'^iptv-playlist-parser$':
|
||||
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
|
||||
'^shaka-player$': '<rootDir>/src/test-stubs/shaka-player.js',
|
||||
'^video.js$': '<rootDir>/src/test-stubs/video-js.js',
|
||||
'^rxjs': '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
|
||||
'^uuid$': '<rootDir>/../../node_modules/uuid/wrapper.mjs',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* CJS test stub for the lazily imported `shaka-player` module (the real
|
||||
* compiled bundle is huge and ESM-hostile under jest). Mirrors the surface in
|
||||
* libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts.
|
||||
*
|
||||
* Specs can inspect `shaka.__instances` / reset with `shaka.__reset()`.
|
||||
*/
|
||||
class MockShakaPlayer {
|
||||
constructor() {
|
||||
this.configureCalls = [];
|
||||
this.loadedUrls = [];
|
||||
this.attachedTo = null;
|
||||
this.destroyCount = 0;
|
||||
this.selectTextTrackCalls = [];
|
||||
this.listeners = new Map();
|
||||
shaka.__instances.push(this);
|
||||
}
|
||||
|
||||
attach(mediaElement) {
|
||||
this.attachedTo = mediaElement;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
configure(config) {
|
||||
this.configureCalls.push(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
load(assetUri) {
|
||||
this.loadedUrls.push(assetUri);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyCount += 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
const set = this.listeners.get(type) || new Set();
|
||||
set.add(listener);
|
||||
this.listeners.set(type, set);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener) {
|
||||
const set = this.listeners.get(type);
|
||||
if (set) {
|
||||
set.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(type, detail) {
|
||||
for (const listener of this.listeners.get(type) || []) {
|
||||
listener({ type, detail });
|
||||
}
|
||||
}
|
||||
|
||||
getAudioTracks() {
|
||||
return [];
|
||||
}
|
||||
|
||||
selectAudioTrack() {}
|
||||
|
||||
getTextTracks() {
|
||||
return [];
|
||||
}
|
||||
|
||||
selectTextTrack(track) {
|
||||
this.selectTextTrackCalls.push(track);
|
||||
}
|
||||
|
||||
isLive() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
MockShakaPlayer.isBrowserSupported = () => true;
|
||||
|
||||
const shaka = {
|
||||
Player: MockShakaPlayer,
|
||||
polyfill: {
|
||||
installAll() {},
|
||||
},
|
||||
__instances: [],
|
||||
__reset() {
|
||||
shaka.__instances.length = 0;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = shaka;
|
||||
module.exports.default = shaka;
|
||||
@@ -31,6 +31,7 @@ export default {
|
||||
tslib: 'tslib/tslib.es6.js',
|
||||
'^iptv-playlist-parser$':
|
||||
'<rootDir>/apps/web/src/test-stubs/iptv-playlist-parser.mjs',
|
||||
'^shaka-player$': '<rootDir>/apps/web/src/test-stubs/shaka-player.js',
|
||||
'^rxjs': '<rootDir>/node_modules/rxjs/dist/bundles/rxjs.umd.js',
|
||||
'^uuid$': '<rootDir>/node_modules/uuid/wrapper.mjs',
|
||||
},
|
||||
|
||||
@@ -83,6 +83,17 @@ describe('ArtPlayer setup', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('routes DASH manifests to the mpd custom type', () => {
|
||||
expect(getArtPlayerVideoType('https://example.test/live.mpd')).toBe(
|
||||
'mpd'
|
||||
);
|
||||
expect(
|
||||
getArtPlayerVideoType(
|
||||
'https://example.test/live.mpd?token=signed#frag'
|
||||
)
|
||||
).toBe('mpd');
|
||||
});
|
||||
|
||||
it('exits fullscreen only when the shared ArtPlayer shell owns it', () => {
|
||||
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
|
||||
document,
|
||||
|
||||
@@ -72,6 +72,8 @@ export function getArtPlayerVideoType(url: string): string {
|
||||
return 'm3u8';
|
||||
case 'mp4':
|
||||
return 'mp4';
|
||||
case 'mpd':
|
||||
return 'mpd';
|
||||
case 'ts':
|
||||
return 'ts';
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type Artplayer from 'artplayer';
|
||||
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
|
||||
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import { WebVideoControlsAdapter } from '../player-controls';
|
||||
import type {
|
||||
ShakaModuleLike,
|
||||
ShakaModuleLoader,
|
||||
} from '../shaka-engine/shaka-module.types';
|
||||
import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session';
|
||||
|
||||
const hlsInstances: MockHls[] = [];
|
||||
@@ -335,16 +340,159 @@ describe('ArtPlayerSourceSession', () => {
|
||||
|
||||
expect(refresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the Shaka engine with channel DRM for the mpd custom type', async () => {
|
||||
const fakeShaka = createFakeShakaModule();
|
||||
const drm: ChannelDrm = {
|
||||
licenseType: 'clearkey',
|
||||
supported: true,
|
||||
clearKeys: { abc: 'def' },
|
||||
};
|
||||
const { session, player, video } = createSession({
|
||||
sharedControls: true,
|
||||
getDrm: () => drm,
|
||||
loadShaka: fakeShaka.loader,
|
||||
});
|
||||
session.attach(player);
|
||||
|
||||
session.customType['mpd']?.(
|
||||
video,
|
||||
'https://example.test/live.mpd',
|
||||
player
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(fakeShaka.loader).toHaveBeenCalledTimes(1);
|
||||
expect(fakeShaka.instances).toHaveLength(1);
|
||||
expect(fakeShaka.instances[0].attachedTo).toBe(video);
|
||||
expect(fakeShaka.instances[0].loadedUrls).toEqual([
|
||||
'https://example.test/live.mpd',
|
||||
]);
|
||||
expect(fakeShaka.instances[0].configureCalls).toEqual([
|
||||
{ drm: { clearKeys: { abc: 'def' } } },
|
||||
]);
|
||||
expect(hlsInstances).toHaveLength(0);
|
||||
expect(mpegTsInstances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('destroys the Shaka engine when the source changes or the session dies', async () => {
|
||||
const fakeShaka = createFakeShakaModule();
|
||||
const { session, player, video } = createSession({
|
||||
sharedControls: false,
|
||||
loadShaka: fakeShaka.loader,
|
||||
});
|
||||
session.attach(player);
|
||||
|
||||
session.customType['mpd']?.(
|
||||
video,
|
||||
'https://example.test/live.mpd',
|
||||
player
|
||||
);
|
||||
await flushAsync();
|
||||
session.customType['m3u8']?.(
|
||||
video,
|
||||
'https://example.test/live.m3u8',
|
||||
player
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(fakeShaka.instances[0].destroyCount).toBe(1);
|
||||
expect(hlsInstances).toHaveLength(1);
|
||||
|
||||
session.destroy();
|
||||
await flushAsync();
|
||||
expect(fakeShaka.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
async function flushAsync(): Promise<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;
|
||||
@@ -368,6 +516,8 @@ function createSession({
|
||||
isLive: () => isLive,
|
||||
showCaptions: () => false,
|
||||
emitPlaybackIssue,
|
||||
getDrm,
|
||||
loadShaka,
|
||||
}),
|
||||
player,
|
||||
video,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type Artplayer from 'artplayer';
|
||||
import type { Option } from 'artplayer';
|
||||
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
|
||||
import mpegts from 'mpegts.js';
|
||||
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
type PlaybackDiagnostic,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import type { WebVideoControlsAdapter } from '../player-controls';
|
||||
import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types';
|
||||
import { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
|
||||
import {
|
||||
type WebVideoControlsSource,
|
||||
WebVideoSourceControlsBridge,
|
||||
@@ -23,6 +26,10 @@ export interface ArtPlayerSourceSessionConfig {
|
||||
isLive: () => boolean;
|
||||
showCaptions: () => boolean;
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void;
|
||||
/** DRM config of the active channel, used by the DASH (`mpd`) engine. */
|
||||
getDrm?: () => ChannelDrm | undefined;
|
||||
/** Test seam for the lazily imported shaka-player module. */
|
||||
loadShaka?: ShakaModuleLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +42,7 @@ export interface ArtPlayerSourceSessionConfig {
|
||||
export class ArtPlayerSourceSession {
|
||||
readonly customType: NonNullable<Option['customType']> = {
|
||||
m3u8: (video, url, art) => this.startHls(video, url, art),
|
||||
mpd: (video, url) => this.startDash(video, url),
|
||||
ts: (video, url) => this.startMpegTs(video, url),
|
||||
'video/matroska': (video, url) => this.startNative(video, url),
|
||||
};
|
||||
@@ -53,6 +61,7 @@ export class ArtPlayerSourceSession {
|
||||
private mpegTsErrorListener:
|
||||
| ((type: string, details: string, info: unknown) => void)
|
||||
| null = null;
|
||||
private shakaSession: ShakaVideoSession | null = null;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(private readonly config: ArtPlayerSourceSessionConfig) {}
|
||||
@@ -99,6 +108,8 @@ export class ArtPlayerSourceSession {
|
||||
this.controlsBridge = null;
|
||||
this.destroyHls();
|
||||
this.destroyMpegTs();
|
||||
this.shakaSession?.destroy();
|
||||
this.shakaSession = null;
|
||||
this.player = null;
|
||||
}
|
||||
|
||||
@@ -181,6 +192,17 @@ export class ArtPlayerSourceSession {
|
||||
}
|
||||
}
|
||||
|
||||
private startDash(video: HTMLVideoElement, url: string): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.prepareForSourceChange();
|
||||
const session = this.getShakaSession();
|
||||
this.bindControlsSource({ kind: 'shaka', session });
|
||||
session.start(video, url, this.config.getDrm?.());
|
||||
}
|
||||
|
||||
private startNative(video: HTMLVideoElement, url: string): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
@@ -195,6 +217,18 @@ export class ArtPlayerSourceSession {
|
||||
this.controlsBridge?.clearSource();
|
||||
this.destroyHls();
|
||||
this.destroyMpegTs();
|
||||
this.shakaSession?.stop();
|
||||
}
|
||||
|
||||
private getShakaSession(): ShakaVideoSession {
|
||||
this.shakaSession ??= new ShakaVideoSession({
|
||||
player: InlinePlaybackPlayer.ArtPlayer,
|
||||
emitPlaybackIssue: (issue) =>
|
||||
this.config.emitPlaybackIssue(issue),
|
||||
showCaptions: this.config.showCaptions,
|
||||
loadShaka: this.config.loadShaka,
|
||||
});
|
||||
return this.shakaSession;
|
||||
}
|
||||
|
||||
private bindControlsSource(source: WebVideoControlsSource): void {
|
||||
|
||||
@@ -134,6 +134,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
isLive: () => this.isLive(),
|
||||
showCaptions: () => this.showCaptions(),
|
||||
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
|
||||
getDrm: () => this.channel().drm,
|
||||
});
|
||||
this.sourceSession = sourceSession;
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ErrorData, ManifestParsedData } from 'hls.js';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
type PlaybackDiagnostic,
|
||||
type PlaybackSourceMetadata,
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyUnsupportedHlsManifestCodecs,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
|
||||
/**
|
||||
* HTML5-player diagnostics glue extracted from the component: builds source
|
||||
* metadata and turns engine error payloads into emitted playback issues.
|
||||
*/
|
||||
|
||||
export function createHtml5SourceMetadata(
|
||||
url: string,
|
||||
mimeType?: string,
|
||||
audioCodecs: readonly string[] = [],
|
||||
videoCodecs: readonly string[] = []
|
||||
): PlaybackSourceMetadata {
|
||||
return createPlaybackSourceMetadata({
|
||||
url,
|
||||
mimeType,
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
audioCodecs,
|
||||
videoCodecs,
|
||||
});
|
||||
}
|
||||
|
||||
export function emitUnsupportedHlsManifestCodecs(
|
||||
url: string,
|
||||
data: ManifestParsedData,
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
|
||||
): void {
|
||||
const metadata = createHtml5SourceMetadata(
|
||||
url,
|
||||
'application/x-mpegURL',
|
||||
data.levels
|
||||
.map((level) => level.audioCodec)
|
||||
.filter((codec): codec is string => Boolean(codec)),
|
||||
data.levels
|
||||
.map((level) => level.videoCodec)
|
||||
.filter((codec): codec is string => Boolean(codec))
|
||||
);
|
||||
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
|
||||
if (issue) {
|
||||
emitPlaybackIssue(issue);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitFatalHlsPlaybackError(
|
||||
url: string,
|
||||
data: ErrorData,
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
|
||||
): void {
|
||||
if (!data.fatal) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitPlaybackIssue(
|
||||
classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: data.type,
|
||||
details: data.details,
|
||||
fatal: data.fatal,
|
||||
message: data.error?.message,
|
||||
error: data.error,
|
||||
},
|
||||
createHtml5SourceMetadata(url, 'application/x-mpegURL')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function emitMpegTsPlaybackError(
|
||||
url: string,
|
||||
error: { type: string; details: string; info: unknown },
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
|
||||
): void {
|
||||
emitPlaybackIssue(
|
||||
classifyMpegTsPlaybackIssue(
|
||||
error,
|
||||
createHtml5SourceMetadata(url, 'video/mp2t')
|
||||
)
|
||||
);
|
||||
}
|
||||
+31
@@ -126,6 +126,37 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('owns one Shaka source for DASH (.mpd) streams', () => {
|
||||
const { component } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures
|
||||
);
|
||||
const setSource = observeBridgeSourceBinding(component);
|
||||
|
||||
component.playChannel({
|
||||
...TEST_CHANNEL,
|
||||
url: 'https://example.test/live.mpd',
|
||||
drm: {
|
||||
licenseType: 'clearkey',
|
||||
supported: true,
|
||||
clearKeys: { abc: 'def' },
|
||||
},
|
||||
});
|
||||
|
||||
const internals = readHtmlPlayerInternals(component);
|
||||
expect(setSource).toHaveBeenCalledTimes(1);
|
||||
expect(setSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'shaka' })
|
||||
);
|
||||
expect(internals.controlsSource).toEqual(
|
||||
expect.objectContaining({ kind: 'shaka' })
|
||||
);
|
||||
expect(internals.hls).toBeNull();
|
||||
expect(internals.mpegtsPlayer).toBeNull();
|
||||
expect(hlsInstances).toHaveLength(0);
|
||||
expect(mpegTsInstances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('owns one native source before loading native media', () => {
|
||||
const { component, fixture } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export type SharedControlsFixture =
|
||||
ComponentFixture<HtmlVideoPlayerComponentInstance>;
|
||||
|
||||
export interface ControlsSource {
|
||||
kind: 'native' | 'mpegts' | 'hls';
|
||||
kind: 'native' | 'mpegts' | 'hls' | 'shaka';
|
||||
}
|
||||
|
||||
export interface HtmlPlayerInternals {
|
||||
|
||||
@@ -20,10 +20,6 @@ import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyUnsupportedHlsManifestCodecs,
|
||||
createPlaybackSourceMetadata,
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import {
|
||||
@@ -33,7 +29,18 @@ import {
|
||||
} from '../player-controls';
|
||||
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
|
||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
||||
import { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
|
||||
import { exitOwnedFullscreen } from '../web-video-support/exit-owned-fullscreen.util';
|
||||
import {
|
||||
clearNativeVideoSources,
|
||||
setNativeVideoSource,
|
||||
} from '../web-video-support/web-video-native-source.util';
|
||||
import { HtmlVideoElementSession } from './html-video-element-session';
|
||||
import {
|
||||
emitFatalHlsPlaybackError,
|
||||
emitMpegTsPlaybackError,
|
||||
emitUnsupportedHlsManifestCodecs,
|
||||
} from './html-video-player-diagnostics';
|
||||
import {
|
||||
HtmlVideoPlayerControlsBridge,
|
||||
type HtmlVideoControlsSource,
|
||||
@@ -88,6 +95,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
hls: Hls | null = null;
|
||||
/** mpegts.js player for raw MPEG-TS streams */
|
||||
private mpegtsPlayer: mpegts.Player | null = null;
|
||||
/** Shaka session for DASH (.mpd) streams, created on first use */
|
||||
private shakaSession: ShakaVideoSession | null = null;
|
||||
private controlsSource: HtmlVideoControlsSource | null = null;
|
||||
private controlsBridge: HtmlVideoPlayerControlsBridge | null = null;
|
||||
private videoSession: HtmlVideoElementSession | null = null;
|
||||
@@ -127,7 +136,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.controlsBridge?.refreshInputs();
|
||||
}
|
||||
if (changes['interactionEnabled']?.currentValue === false) {
|
||||
this.exitOwnedFullscreen();
|
||||
exitOwnedFullscreen(
|
||||
this.sharedControls,
|
||||
this.playerRoot()?.nativeElement,
|
||||
(error) =>
|
||||
debugHtmlPlayer(
|
||||
'Failed to exit HTML5 player fullscreen:',
|
||||
error
|
||||
)
|
||||
);
|
||||
}
|
||||
if (changes['volume']?.currentValue !== undefined) {
|
||||
debugHtmlPlayer(
|
||||
@@ -139,29 +156,6 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private exitOwnedFullscreen(): void {
|
||||
if (
|
||||
!this.sharedControls ||
|
||||
document.fullscreenElement !== this.playerRoot()?.nativeElement ||
|
||||
typeof document.exitFullscreen !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void Promise.resolve(document.exitFullscreen()).catch(
|
||||
(error: unknown) => {
|
||||
debugHtmlPlayer(
|
||||
'Failed to exit HTML5 player fullscreen:',
|
||||
error
|
||||
);
|
||||
}
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
debugHtmlPlayer('Failed to exit HTML5 player fullscreen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts to play the given channel
|
||||
* @param channel given channel object
|
||||
@@ -170,7 +164,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.clearControlsSource();
|
||||
this.destroyMpegtsPlayer();
|
||||
this.destroyHls();
|
||||
this.clearNativeVideoSources(this.videoPlayer.nativeElement);
|
||||
this.shakaSession?.stop();
|
||||
clearNativeVideoSources(this.videoPlayer.nativeElement);
|
||||
if (channel.url) {
|
||||
this.playbackIssue.emit(null);
|
||||
const url = channel.url + (channel.epgParams ?? '');
|
||||
@@ -189,7 +184,24 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
);
|
||||
});
|
||||
|
||||
if ((extension === 'ts' || !extension) && mpegts.isSupported()) {
|
||||
if (extension === 'mpd') {
|
||||
debugHtmlPlayer(
|
||||
'Using Shaka Player for DASH stream:',
|
||||
channel.name,
|
||||
url
|
||||
);
|
||||
const session = this.getShakaSession();
|
||||
this.bindControlsSource({ kind: 'shaka', session });
|
||||
session.start(
|
||||
this.videoPlayer.nativeElement,
|
||||
url,
|
||||
channel.drm
|
||||
);
|
||||
this.handlePlayOperation();
|
||||
} else if (
|
||||
(extension === 'ts' || !extension) &&
|
||||
mpegts.isSupported()
|
||||
) {
|
||||
debugHtmlPlayer(
|
||||
'Using mpegts.js for TS stream:',
|
||||
channel.name,
|
||||
@@ -207,15 +219,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.mpegtsPlayer.on(
|
||||
mpegts.Events.ERROR,
|
||||
(type: string, details: string, info: unknown): void => {
|
||||
this.playbackIssue.emit(
|
||||
classifyMpegTsPlaybackIssue(
|
||||
{
|
||||
type,
|
||||
details,
|
||||
info,
|
||||
},
|
||||
this.createSourceMetadata(url, 'video/mp2t')
|
||||
)
|
||||
emitMpegTsPlaybackError(
|
||||
url,
|
||||
{ type, details, info },
|
||||
(issue) => this.playbackIssue.emit(issue)
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -242,35 +249,18 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.handlePlayOperation();
|
||||
} else {
|
||||
debugHtmlPlayer('Using native video player');
|
||||
this.replaceNativeVideoSource(
|
||||
setNativeVideoSource(
|
||||
this.videoPlayer.nativeElement,
|
||||
url,
|
||||
'video/mp4'
|
||||
);
|
||||
this.bindControlsSource({ kind: 'native' });
|
||||
this.videoPlayer.nativeElement.load();
|
||||
this.handlePlayOperation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearNativeVideoSources(element: HTMLVideoElement): void {
|
||||
element.removeAttribute('src');
|
||||
element.replaceChildren();
|
||||
}
|
||||
|
||||
private replaceNativeVideoSource(
|
||||
element: HTMLVideoElement,
|
||||
url: string,
|
||||
type: string
|
||||
): void {
|
||||
this.clearNativeVideoSources(element);
|
||||
const source = document.createElement('source');
|
||||
source.src = url;
|
||||
source.type = type;
|
||||
element.appendChild(source);
|
||||
this.bindControlsSource({ kind: 'native' });
|
||||
element.load();
|
||||
}
|
||||
|
||||
private bindControlsSource(source: HtmlVideoControlsSource): void {
|
||||
this.controlsSource = source;
|
||||
this.controlsBridge?.setSource(source);
|
||||
@@ -299,6 +289,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
hls?.destroy();
|
||||
}
|
||||
|
||||
private getShakaSession(): ShakaVideoSession {
|
||||
this.shakaSession ??= new ShakaVideoSession({
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
|
||||
showCaptions: () => this.showCaptions(),
|
||||
});
|
||||
return this.shakaSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables text based captions based on the global settings
|
||||
*/
|
||||
@@ -317,56 +316,17 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
url: string,
|
||||
data: ManifestParsedData
|
||||
): void {
|
||||
const metadata = this.createSourceMetadata(
|
||||
url,
|
||||
'application/x-mpegURL',
|
||||
data.levels
|
||||
.map((level) => level.audioCodec)
|
||||
.filter((codec): codec is string => Boolean(codec)),
|
||||
data.levels
|
||||
.map((level) => level.videoCodec)
|
||||
.filter((codec): codec is string => Boolean(codec))
|
||||
emitUnsupportedHlsManifestCodecs(url, data, (issue) =>
|
||||
this.playbackIssue.emit(issue)
|
||||
);
|
||||
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
|
||||
if (issue) {
|
||||
this.playbackIssue.emit(issue);
|
||||
}
|
||||
}
|
||||
|
||||
private handleHlsError(url: string, data: ErrorData): void {
|
||||
if (!data.fatal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playbackIssue.emit(
|
||||
classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: data.type,
|
||||
details: data.details,
|
||||
fatal: data.fatal,
|
||||
message: data.error?.message,
|
||||
error: data.error,
|
||||
},
|
||||
this.createSourceMetadata(url, 'application/x-mpegURL')
|
||||
)
|
||||
emitFatalHlsPlaybackError(url, data, (issue) =>
|
||||
this.playbackIssue.emit(issue)
|
||||
);
|
||||
}
|
||||
|
||||
private createSourceMetadata(
|
||||
url: string,
|
||||
mimeType?: string,
|
||||
audioCodecs: readonly string[] = [],
|
||||
videoCodecs: readonly string[] = []
|
||||
) {
|
||||
return createPlaybackSourceMetadata({
|
||||
url,
|
||||
mimeType,
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
audioCodecs,
|
||||
videoCodecs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy hls instance on component destroy and clean up event listener
|
||||
*/
|
||||
@@ -378,6 +338,8 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.videoSession = null;
|
||||
this.destroyMpegtsPlayer();
|
||||
this.destroyHls();
|
||||
this.shakaSession?.destroy();
|
||||
this.shakaSession = null;
|
||||
}
|
||||
|
||||
private getVideoSession(): HtmlVideoElementSession {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const PlaybackDiagnosticSource = {
|
||||
Native: 'native',
|
||||
Hls: 'hls',
|
||||
MpegTs: 'mpegts',
|
||||
Shaka: 'shaka',
|
||||
} as const;
|
||||
|
||||
export type PlaybackDiagnosticSource =
|
||||
|
||||
@@ -45,7 +45,7 @@ export function classifyNativePlaybackIssue(
|
||||
// Native MediaError details are often opaque for browser security
|
||||
// failures. Only classify browser access when the runtime exposes a
|
||||
// concrete CORS/mixed-content/CSP-style message.
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: isBrowserAccessFailure(lowerNativeErrorMessage)
|
||||
? DiagnosticCode.BrowserAccessError
|
||||
: DiagnosticCode.NetworkError,
|
||||
@@ -57,7 +57,7 @@ export function classifyNativePlaybackIssue(
|
||||
}
|
||||
|
||||
if (nativeErrorCode === DECODE_ERROR_CODE) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.MediaDecodeError,
|
||||
source: DiagnosticSource.Native,
|
||||
metadata,
|
||||
@@ -67,7 +67,7 @@ export function classifyNativePlaybackIssue(
|
||||
}
|
||||
|
||||
if (nativeErrorCode === SOURCE_NOT_SUPPORTED_CODE) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: isLikelyContainerIssue(metadata)
|
||||
? DiagnosticCode.UnsupportedContainer
|
||||
: DiagnosticCode.UnsupportedCodec,
|
||||
@@ -78,7 +78,7 @@ export function classifyNativePlaybackIssue(
|
||||
});
|
||||
}
|
||||
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnknownPlaybackError,
|
||||
source: DiagnosticSource.Native,
|
||||
metadata,
|
||||
@@ -100,7 +100,7 @@ export function classifyHlsPlaybackIssue(
|
||||
});
|
||||
|
||||
if (isNetworkFailure(lowerType, lowerDetails)) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: isBrowserAccessFailure(lowerDetails)
|
||||
? DiagnosticCode.BrowserAccessError
|
||||
: DiagnosticCode.NetworkError,
|
||||
@@ -111,7 +111,7 @@ export function classifyHlsPlaybackIssue(
|
||||
}
|
||||
|
||||
if (isDrmOrEncryptionFailure(lowerDetails)) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.DrmOrEncryption,
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
@@ -120,7 +120,7 @@ export function classifyHlsPlaybackIssue(
|
||||
}
|
||||
|
||||
if (isCodecFailure(lowerDetails)) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedCodec,
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
@@ -129,7 +129,7 @@ export function classifyHlsPlaybackIssue(
|
||||
}
|
||||
|
||||
if (lowerType.includes('media') || lowerType.includes('mux')) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.MediaDecodeError,
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
@@ -137,7 +137,7 @@ export function classifyHlsPlaybackIssue(
|
||||
});
|
||||
}
|
||||
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnknownPlaybackError,
|
||||
source: DiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
@@ -154,7 +154,7 @@ export function classifyMpegTsPlaybackIssue(
|
||||
const lowerType = (error.type ?? '').toLowerCase();
|
||||
|
||||
if (isEarlyEofFailure(lowerDetails)) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.MediaDecodeError,
|
||||
source: DiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
@@ -163,7 +163,7 @@ export function classifyMpegTsPlaybackIssue(
|
||||
}
|
||||
|
||||
if (isNetworkFailure(lowerType, lowerDetails)) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: isBrowserAccessFailure(lowerDetails)
|
||||
? DiagnosticCode.BrowserAccessError
|
||||
: DiagnosticCode.NetworkError,
|
||||
@@ -174,7 +174,7 @@ export function classifyMpegTsPlaybackIssue(
|
||||
}
|
||||
|
||||
if (lowerDetails.includes('codec')) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedCodec,
|
||||
source: DiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
@@ -183,7 +183,7 @@ export function classifyMpegTsPlaybackIssue(
|
||||
}
|
||||
|
||||
if (lowerDetails.includes('format') || lowerDetails.includes('mse')) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedContainer,
|
||||
source: DiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
@@ -192,7 +192,7 @@ export function classifyMpegTsPlaybackIssue(
|
||||
}
|
||||
|
||||
if (lowerType.includes('media')) {
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.MediaDecodeError,
|
||||
source: DiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
@@ -200,7 +200,7 @@ export function classifyMpegTsPlaybackIssue(
|
||||
});
|
||||
}
|
||||
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnknownPlaybackError,
|
||||
source: DiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
@@ -222,7 +222,7 @@ export function classifyUnsupportedHlsManifestCodecs(
|
||||
return null;
|
||||
}
|
||||
|
||||
return createDiagnostic({
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedCodec,
|
||||
source: DiagnosticSource.Source,
|
||||
metadata,
|
||||
@@ -230,7 +230,7 @@ export function classifyUnsupportedHlsManifestCodecs(
|
||||
});
|
||||
}
|
||||
|
||||
function createDiagnostic(options: {
|
||||
export function createPlaybackDiagnostic(options: {
|
||||
readonly code: PlaybackDiagnosticCode;
|
||||
readonly source: PlaybackDiagnosticSource;
|
||||
readonly metadata: PlaybackSourceMetadata;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import {
|
||||
classifyShakaPlaybackIssue,
|
||||
createUnsupportedDrmDiagnostic,
|
||||
} from './shaka-error-classifier';
|
||||
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: 'http://example.com/stream.mpd',
|
||||
mimeType: 'application/dash+xml',
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
});
|
||||
|
||||
describe('classifyShakaPlaybackIssue', () => {
|
||||
it.each([
|
||||
['DRM category', { category: 6, code: 6001 }],
|
||||
['restrictions-cannot-be-met', { category: 4, code: 4012 }],
|
||||
[
|
||||
'license keyword in message',
|
||||
{ category: 5, code: 5001, message: 'license request failed' },
|
||||
],
|
||||
])('maps %s to DrmOrEncryption', (_label, error) => {
|
||||
const issue = classifyShakaPlaybackIssue(error, metadata);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka);
|
||||
});
|
||||
|
||||
it('maps network category to NetworkError', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 1, code: 1001 },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
});
|
||||
|
||||
it('maps CORS-flavored network failures to BrowserAccessError', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 1, code: 1002, message: 'Blocked by CORS policy' },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.BrowserAccessError);
|
||||
});
|
||||
|
||||
it('maps media category to MediaDecodeError', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 3, code: 3016 },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
});
|
||||
|
||||
it('maps codec-flavored media failures to UnsupportedCodec', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 3, code: 3005, message: 'addCodec failed for hvc1' },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
});
|
||||
|
||||
it('maps manifest category to UnsupportedContainer', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 4, code: 4001 },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedContainer);
|
||||
});
|
||||
|
||||
it('falls back to UnknownPlaybackError and keeps details', () => {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
{ category: 7, code: 7002, message: 'boom' },
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue.details).toContain('boom');
|
||||
expect(issue.details).toContain('7002');
|
||||
});
|
||||
|
||||
it('tolerates missing error objects', () => {
|
||||
const issue = classifyShakaPlaybackIssue(null, metadata);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUnsupportedDrmDiagnostic', () => {
|
||||
it('creates a DRM diagnostic carrying the license type', () => {
|
||||
const issue = createUnsupportedDrmDiagnostic(
|
||||
'com.widevine.alpha',
|
||||
metadata
|
||||
);
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue.source).toBe(PlaybackDiagnosticSource.Shaka);
|
||||
expect(issue.details).toContain('com.widevine.alpha');
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
PlaybackDiagnostic,
|
||||
PlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
PlaybackDiagnosticCode as DiagnosticCode,
|
||||
PlaybackDiagnosticSource as DiagnosticSource,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import {
|
||||
isBrowserAccessFailure,
|
||||
isCodecFailure,
|
||||
isDrmOrEncryptionFailure,
|
||||
} from '../playback-diagnostics/playback-error-patterns.util';
|
||||
import type { ShakaErrorLike } from './shaka-module.types';
|
||||
|
||||
/**
|
||||
* Numeric `shaka.util.Error.Category` values (stable public Shaka API). Kept
|
||||
* local so classification stays a pure function that does not need the lazily
|
||||
* loaded module.
|
||||
*/
|
||||
const SHAKA_CATEGORY = {
|
||||
Network: 1,
|
||||
Text: 2,
|
||||
Media: 3,
|
||||
Manifest: 4,
|
||||
Streaming: 5,
|
||||
Drm: 6,
|
||||
} as const;
|
||||
|
||||
/** `shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET` — in practice this fires
|
||||
* when every variant is restricted by unusable decryption keys. */
|
||||
const RESTRICTIONS_CANNOT_BE_MET = 4012;
|
||||
|
||||
export function classifyShakaPlaybackIssue(
|
||||
error: Partial<ShakaErrorLike> | null | undefined,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const details = formatShakaErrorDetails(error);
|
||||
const lowerDetails = details.toLowerCase();
|
||||
const category = error?.category;
|
||||
|
||||
if (
|
||||
category === SHAKA_CATEGORY.Drm ||
|
||||
error?.code === RESTRICTIONS_CANNOT_BE_MET ||
|
||||
isDrmOrEncryptionFailure(lowerDetails)
|
||||
) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.DrmOrEncryption,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (category === SHAKA_CATEGORY.Network) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: isBrowserAccessFailure(lowerDetails)
|
||||
? DiagnosticCode.BrowserAccessError
|
||||
: DiagnosticCode.NetworkError,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
category === SHAKA_CATEGORY.Media ||
|
||||
category === SHAKA_CATEGORY.Streaming
|
||||
) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: isCodecFailure(lowerDetails)
|
||||
? DiagnosticCode.UnsupportedCodec
|
||||
: DiagnosticCode.MediaDecodeError,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (category === SHAKA_CATEGORY.Manifest) {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedContainer,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnknownPlaybackError,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic for `.mpd` channels that declare a DRM system the app cannot
|
||||
* handle (Widevine, PlayReady, malformed ClearKey config, …). Emitted before
|
||||
* any Shaka engine is started.
|
||||
*/
|
||||
export function createUnsupportedDrmDiagnostic(
|
||||
licenseType: string,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.DrmOrEncryption,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details: licenseType
|
||||
? `Unsupported DRM license configuration: ${licenseType}`
|
||||
: 'Unsupported DRM license configuration',
|
||||
});
|
||||
}
|
||||
|
||||
function formatShakaErrorDetails(
|
||||
error: Partial<ShakaErrorLike> | null | undefined
|
||||
): string {
|
||||
if (!error) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = [
|
||||
typeof error.category === 'number'
|
||||
? `Shaka category ${error.category}`
|
||||
: '',
|
||||
typeof error.code === 'number' ? `code ${error.code}` : '',
|
||||
error.message ?? '',
|
||||
stringifyErrorData(error.data),
|
||||
];
|
||||
|
||||
return parts.filter((part) => part.length > 0).join(' ');
|
||||
}
|
||||
|
||||
function stringifyErrorData(data: unknown[] | undefined): string {
|
||||
if (!data || data.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(data);
|
||||
return serialized === '[]' ? '' : serialized;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Minimal structural typings for the lazily imported `shaka-player` module.
|
||||
*
|
||||
* The vendor package ships clutz-generated global namespace typings that are
|
||||
* awkward to consume from an ESM library. These interfaces cover exactly the
|
||||
* surface the app uses and double as the seam for unit-test fakes: the session
|
||||
* accepts any {@link ShakaModuleLoader}, so specs never touch the real module.
|
||||
*/
|
||||
|
||||
export interface ShakaAudioTrackLike {
|
||||
active: boolean;
|
||||
language: string;
|
||||
label: string | null;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface ShakaTextTrackLike {
|
||||
id: number;
|
||||
active: boolean;
|
||||
language: string;
|
||||
label: string | null;
|
||||
kind: string | null;
|
||||
}
|
||||
|
||||
export interface ShakaErrorLike {
|
||||
severity: number;
|
||||
category: number;
|
||||
code: number;
|
||||
message?: string;
|
||||
data?: unknown[];
|
||||
}
|
||||
|
||||
export interface ShakaPlayerLike {
|
||||
attach(mediaElement: HTMLMediaElement): Promise<unknown>;
|
||||
configure(config: Record<string, unknown>): boolean;
|
||||
load(assetUri: string): Promise<unknown>;
|
||||
destroy(): Promise<unknown>;
|
||||
addEventListener(type: string, listener: (event: Event) => void): void;
|
||||
removeEventListener(type: string, listener: (event: Event) => void): void;
|
||||
getAudioTracks(): ShakaAudioTrackLike[];
|
||||
selectAudioTrack(track: ShakaAudioTrackLike): void;
|
||||
getTextTracks(): ShakaTextTrackLike[];
|
||||
/**
|
||||
* Shaka 5 visibility model: selecting a track shows it, `null` unloads
|
||||
* the active track (subtitles off). There is no separate visibility API.
|
||||
*/
|
||||
selectTextTrack(track: ShakaTextTrackLike | null): void;
|
||||
isLive(): boolean;
|
||||
}
|
||||
|
||||
export interface ShakaModuleLike {
|
||||
Player: {
|
||||
new (): ShakaPlayerLike;
|
||||
isBrowserSupported(): boolean;
|
||||
};
|
||||
polyfill: {
|
||||
installAll(): void;
|
||||
};
|
||||
}
|
||||
|
||||
export type ShakaModuleLoader = () => Promise<ShakaModuleLike>;
|
||||
|
||||
/**
|
||||
* Default production loader. The dynamic import keeps shaka-player (~267 KB
|
||||
* gzip) out of the main bundle until the first `.mpd` stream starts.
|
||||
*/
|
||||
export const loadShakaModule: ShakaModuleLoader = async () => {
|
||||
const module = (await import('shaka-player')) as unknown as
|
||||
| { default?: ShakaModuleLike }
|
||||
| ShakaModuleLike;
|
||||
return (
|
||||
((module as { default?: ShakaModuleLike }).default ??
|
||||
module) as ShakaModuleLike
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import type {
|
||||
ShakaModuleLike,
|
||||
ShakaPlayerLike,
|
||||
} from './shaka-module.types';
|
||||
import { ShakaVideoSession } from './shaka-video-session';
|
||||
|
||||
type Listener = (event: Event) => void;
|
||||
|
||||
class FakeShakaPlayer implements ShakaPlayerLike {
|
||||
static instances: FakeShakaPlayer[] = [];
|
||||
readonly configureCalls: Record<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 = () =>
|
||||
new ShakaVideoSession({
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
emitPlaybackIssue: (issue) => issues.push(issue),
|
||||
showCaptions: () => false,
|
||||
loadShaka,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
FakeShakaPlayer.instances = [];
|
||||
issues = [];
|
||||
browserSupported = true;
|
||||
installAll = jest.fn();
|
||||
fakeModule = {
|
||||
Player: Object.assign(
|
||||
function (this: unknown) {
|
||||
return new FakeShakaPlayer();
|
||||
} as unknown as ShakaModuleLike['Player'],
|
||||
{ isBrowserSupported: () => browserSupported }
|
||||
),
|
||||
polyfill: { installAll },
|
||||
};
|
||||
loadShaka = jest.fn().mockResolvedValue(fakeModule);
|
||||
});
|
||||
|
||||
it('lazily loads the module once, installs polyfills and starts playback', async () => {
|
||||
const session = createSession();
|
||||
session.start(video, 'http://example.com/a.mpd');
|
||||
await flush();
|
||||
session.start(video, 'http://example.com/b.mpd');
|
||||
await flush();
|
||||
|
||||
expect(loadShaka).toHaveBeenCalledTimes(1);
|
||||
expect(installAll).toHaveBeenCalledTimes(1);
|
||||
const [first, second] = FakeShakaPlayer.instances;
|
||||
expect(first.attachedTo).toBe(video);
|
||||
expect(first.loadedUrls).toEqual(['http://example.com/a.mpd']);
|
||||
expect(first.destroyCount).toBe(1);
|
||||
expect(second.loadedUrls).toEqual(['http://example.com/b.mpd']);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
it('configures ClearKey keys before load for supported DRM', async () => {
|
||||
const drm: ChannelDrm = {
|
||||
licenseType: 'clearkey',
|
||||
supported: true,
|
||||
clearKeys: { abc: 'def' },
|
||||
};
|
||||
const session = createSession();
|
||||
session.start(video, 'http://example.com/enc.mpd', drm);
|
||||
await flush();
|
||||
|
||||
const player = FakeShakaPlayer.instances[0];
|
||||
expect(player.configureCalls).toEqual([
|
||||
{ drm: { clearKeys: { abc: 'def' } } },
|
||||
]);
|
||||
expect(player.loadedUrls).toEqual(['http://example.com/enc.mpd']);
|
||||
});
|
||||
|
||||
it('starts without DRM config for clear DASH channels', async () => {
|
||||
const session = createSession();
|
||||
session.start(video, 'http://example.com/clear.mpd');
|
||||
await flush();
|
||||
|
||||
expect(FakeShakaPlayer.instances[0].configureCalls).toEqual([]);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits a DRM diagnostic and starts no engine for unsupported license types', async () => {
|
||||
const session = createSession();
|
||||
session.start(video, 'http://example.com/wv.mpd', {
|
||||
licenseType: 'com.widevine.alpha',
|
||||
supported: false,
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(loadShaka).not.toHaveBeenCalled();
|
||||
expect(FakeShakaPlayer.instances).toHaveLength(0);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issues[0].source).toBe(PlaybackDiagnosticSource.Shaka);
|
||||
expect(issues[0].details).toContain('com.widevine.alpha');
|
||||
});
|
||||
|
||||
it('classifies critical shaka error events and ignores recoverable ones', async () => {
|
||||
const session = createSession();
|
||||
session.start(video, 'http://example.com/a.mpd');
|
||||
await flush();
|
||||
|
||||
const player = FakeShakaPlayer.instances[0];
|
||||
player.dispatch('error', { severity: 1, category: 1, code: 1002 });
|
||||
expect(issues).toEqual([]);
|
||||
|
||||
player.dispatch('error', { severity: 2, category: 6, code: 6001 });
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
});
|
||||
|
||||
it('emits a classified diagnostic when load rejects with a shaka error', async () => {
|
||||
const loadError = { severity: 2, category: 4, code: 4001 };
|
||||
loadShaka.mockResolvedValue({
|
||||
...fakeModule,
|
||||
Player: Object.assign(
|
||||
function (this: unknown) {
|
||||
const player = new FakeShakaPlayer();
|
||||
player.loadResult = Promise.reject(loadError);
|
||||
return player;
|
||||
} as unknown as ShakaModuleLike['Player'],
|
||||
{ isBrowserSupported: () => true }
|
||||
),
|
||||
});
|
||||
|
||||
const session = createSession();
|
||||
session.start(video, 'http://example.com/bad.mpd');
|
||||
await flush();
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(
|
||||
PlaybackDiagnosticCode.UnsupportedContainer
|
||||
);
|
||||
expect(issues[0].sourceUrl).toBe('http://example.com/bad.mpd');
|
||||
});
|
||||
|
||||
it('suppresses interrupted loads and stale results after a channel switch', async () => {
|
||||
let resolveLoad: () => void = () => undefined;
|
||||
loadShaka.mockResolvedValue({
|
||||
...fakeModule,
|
||||
Player: Object.assign(
|
||||
function (this: unknown) {
|
||||
const player = new FakeShakaPlayer();
|
||||
if (FakeShakaPlayer.instances.length === 1) {
|
||||
player.loadResult = new Promise<unknown>((resolve) => {
|
||||
resolveLoad = () => resolve(undefined);
|
||||
});
|
||||
}
|
||||
return player;
|
||||
} as unknown as ShakaModuleLike['Player'],
|
||||
{ isBrowserSupported: () => true }
|
||||
),
|
||||
});
|
||||
|
||||
const session = createSession();
|
||||
session.start(video, 'http://example.com/slow.mpd');
|
||||
await flush();
|
||||
session.start(video, 'http://example.com/fast.mpd');
|
||||
resolveLoad();
|
||||
await flush();
|
||||
|
||||
const [slow, fast] = FakeShakaPlayer.instances;
|
||||
expect(slow.destroyCount).toBe(1);
|
||||
expect(fast.loadedUrls).toEqual(['http://example.com/fast.mpd']);
|
||||
// The slow player resolved after being superseded; its post-load
|
||||
// caption suppression must not have been applied.
|
||||
expect(slow.selectTextTrackCalls).toEqual([]);
|
||||
expect(fast.selectTextTrackCalls).toEqual([null]);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits an unsupported-container diagnostic when the browser lacks MSE/EME', async () => {
|
||||
browserSupported = false;
|
||||
const session = createSession();
|
||||
session.start(video, 'http://example.com/a.mpd');
|
||||
await flush();
|
||||
|
||||
expect(FakeShakaPlayer.instances).toHaveLength(0);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(
|
||||
PlaybackDiagnosticCode.UnsupportedContainer
|
||||
);
|
||||
});
|
||||
|
||||
it('destroy tears down the engine and blocks later starts', async () => {
|
||||
const session = createSession();
|
||||
session.start(video, 'http://example.com/a.mpd');
|
||||
await flush();
|
||||
session.destroy();
|
||||
await flush();
|
||||
session.start(video, 'http://example.com/b.mpd');
|
||||
await flush();
|
||||
|
||||
expect(FakeShakaPlayer.instances).toHaveLength(1);
|
||||
expect(FakeShakaPlayer.instances[0].destroyCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
PlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import { PlaybackDiagnosticCode as DiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import { PlaybackDiagnosticSource as DiagnosticSource } from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import {
|
||||
createPlaybackDiagnostic,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import {
|
||||
classifyShakaPlaybackIssue,
|
||||
createUnsupportedDrmDiagnostic,
|
||||
} from './shaka-error-classifier';
|
||||
import {
|
||||
loadShakaModule,
|
||||
type ShakaErrorLike,
|
||||
type ShakaModuleLike,
|
||||
type ShakaModuleLoader,
|
||||
type ShakaPlayerLike,
|
||||
} from './shaka-module.types';
|
||||
|
||||
export interface ShakaVideoSessionConfig {
|
||||
player: InlinePlaybackPlayer;
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void;
|
||||
showCaptions?: () => boolean;
|
||||
loadShaka?: ShakaModuleLoader;
|
||||
}
|
||||
|
||||
const DASH_MIME_TYPE = 'application/dash+xml';
|
||||
/** `shaka.util.Error.Severity.RECOVERABLE` — Shaka retries these itself. */
|
||||
const SHAKA_SEVERITY_RECOVERABLE = 1;
|
||||
/** `shaka.util.Error.Code.LOAD_INTERRUPTED` — expected when a newer
|
||||
* `load()`/`destroy()` supersedes an in-flight one. */
|
||||
const SHAKA_LOAD_INTERRUPTED = 7000;
|
||||
|
||||
const PLAYER_REFRESH_EVENTS = [
|
||||
'loaded',
|
||||
'trackschanged',
|
||||
'adaptation',
|
||||
'variantchanged',
|
||||
'textchanged',
|
||||
'texttrackvisibility',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Owns one Shaka Player engine for DASH (`.mpd`) playback, mirroring how
|
||||
* hls.js/mpegts.js engines are owned by their player components.
|
||||
*
|
||||
* - `shaka-player` is loaded lazily on the first start; the module is cached.
|
||||
* - `start()`/`stop()` are synchronous entry points; the async work is
|
||||
* serialized on an internal chain and guarded by a generation counter, so a
|
||||
* channel switch during an in-flight load can never resurrect a stale
|
||||
* engine.
|
||||
* - ClearKey DRM comes from the channel's {@link ChannelDrm}; unsupported
|
||||
* license types emit a DRM diagnostic instead of starting an engine.
|
||||
*/
|
||||
export class ShakaVideoSession {
|
||||
private module: ShakaModuleLike | null = null;
|
||||
private player: ShakaPlayerLike | null = null;
|
||||
private playerErrorListener: ((event: Event) => void) | null = null;
|
||||
private playerRefreshListener: (() => void) | null = null;
|
||||
private readonly refreshListeners = new Set<() => void>();
|
||||
private operationChain: Promise<void> = Promise.resolve();
|
||||
private generation = 0;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(private readonly config: ShakaVideoSessionConfig) {}
|
||||
|
||||
/** Starts DASH playback of `url` on `video`. Synchronous entry point. */
|
||||
start(video: HTMLVideoElement, url: string, drm?: ChannelDrm): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (drm && !drm.supported) {
|
||||
this.stop();
|
||||
this.config.emitPlaybackIssue(
|
||||
createUnsupportedDrmDiagnostic(
|
||||
drm.licenseType,
|
||||
this.createMetadata(url)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = ++this.generation;
|
||||
this.enqueue(async () => {
|
||||
await this.destroyPlayer();
|
||||
if (this.isStale(generation)) {
|
||||
return;
|
||||
}
|
||||
await this.startPlayback(generation, video, url, drm);
|
||||
});
|
||||
}
|
||||
|
||||
/** Tears down the current engine (if any) without ending the session. */
|
||||
stop(): void {
|
||||
this.generation += 1;
|
||||
this.enqueue(() => this.destroyPlayer());
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.destroyed = true;
|
||||
this.refreshListeners.clear();
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** Current engine, for the shared-controls track bridge. */
|
||||
getPlayer(): ShakaPlayerLike | null {
|
||||
return this.player;
|
||||
}
|
||||
|
||||
/** Registers a callback fired whenever tracks/text state may have changed. */
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.refreshListeners.add(listener);
|
||||
return () => this.refreshListeners.delete(listener);
|
||||
}
|
||||
|
||||
private async startPlayback(
|
||||
generation: number,
|
||||
video: HTMLVideoElement,
|
||||
url: string,
|
||||
drm: ChannelDrm | undefined
|
||||
): Promise<void> {
|
||||
let module: ShakaModuleLike;
|
||||
try {
|
||||
module = await this.loadModule();
|
||||
} catch (error: unknown) {
|
||||
this.emitIfCurrent(
|
||||
generation,
|
||||
classifyShakaPlaybackIssue(
|
||||
{ message: toErrorMessage(error) },
|
||||
this.createMetadata(url)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isStale(generation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!module.Player.isBrowserSupported()) {
|
||||
this.emitIfCurrent(
|
||||
generation,
|
||||
createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.UnsupportedContainer,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata: this.createMetadata(url),
|
||||
details: 'Shaka Player is not supported in this browser',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const player = new module.Player();
|
||||
this.player = player;
|
||||
this.bindPlayerListeners(player, generation, url);
|
||||
|
||||
if (drm?.clearKeys) {
|
||||
player.configure({ drm: { clearKeys: drm.clearKeys } });
|
||||
}
|
||||
|
||||
try {
|
||||
await player.attach(video);
|
||||
await player.load(url);
|
||||
} catch (error: unknown) {
|
||||
this.handleLoadFailure(generation, player, url, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isStale(generation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Shaka 5 shows a text track by selecting it; keep captions off when
|
||||
// the preference is disabled in case the manifest auto-selected one.
|
||||
if (!(this.config.showCaptions?.() ?? false)) {
|
||||
player.selectTextTrack(null);
|
||||
}
|
||||
this.notifyRefresh();
|
||||
}
|
||||
|
||||
private handleLoadFailure(
|
||||
generation: number,
|
||||
player: ShakaPlayerLike,
|
||||
url: string,
|
||||
error: unknown
|
||||
): void {
|
||||
const shakaError = asShakaError(error);
|
||||
if (
|
||||
this.isStale(generation) ||
|
||||
this.player !== player ||
|
||||
shakaError?.code === SHAKA_LOAD_INTERRUPTED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.emitPlaybackIssue(
|
||||
classifyShakaPlaybackIssue(
|
||||
shakaError ?? { message: toErrorMessage(error) },
|
||||
this.createMetadata(url)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private bindPlayerListeners(
|
||||
player: ShakaPlayerLike,
|
||||
generation: number,
|
||||
url: string
|
||||
): void {
|
||||
const errorListener = (event: Event): void => {
|
||||
if (this.isStale(generation) || this.player !== player) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = (event as { detail?: Partial<ShakaErrorLike> })
|
||||
.detail;
|
||||
if (detail?.severity === SHAKA_SEVERITY_RECOVERABLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.emitPlaybackIssue(
|
||||
classifyShakaPlaybackIssue(detail, this.createMetadata(url))
|
||||
);
|
||||
};
|
||||
const refreshListener = (): void => {
|
||||
if (!this.isStale(generation) && this.player === player) {
|
||||
this.notifyRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
this.playerErrorListener = errorListener;
|
||||
this.playerRefreshListener = refreshListener;
|
||||
player.addEventListener('error', errorListener);
|
||||
for (const event of PLAYER_REFRESH_EVENTS) {
|
||||
player.addEventListener(event, refreshListener);
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyPlayer(): Promise<void> {
|
||||
const player = this.player;
|
||||
this.player = null;
|
||||
if (!player) {
|
||||
this.playerErrorListener = null;
|
||||
this.playerRefreshListener = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playerErrorListener) {
|
||||
player.removeEventListener('error', this.playerErrorListener);
|
||||
}
|
||||
if (this.playerRefreshListener) {
|
||||
for (const event of PLAYER_REFRESH_EVENTS) {
|
||||
player.removeEventListener(event, this.playerRefreshListener);
|
||||
}
|
||||
}
|
||||
this.playerErrorListener = null;
|
||||
this.playerRefreshListener = null;
|
||||
this.notifyRefresh();
|
||||
|
||||
try {
|
||||
await player.destroy();
|
||||
} catch {
|
||||
// Destroy failures leave nothing actionable for the session.
|
||||
}
|
||||
}
|
||||
|
||||
private async loadModule(): Promise<ShakaModuleLike> {
|
||||
if (this.module) {
|
||||
return this.module;
|
||||
}
|
||||
|
||||
const loader = this.config.loadShaka ?? loadShakaModule;
|
||||
const module = await loader();
|
||||
module.polyfill.installAll();
|
||||
this.module = module;
|
||||
return module;
|
||||
}
|
||||
|
||||
private createMetadata(url: string): PlaybackSourceMetadata {
|
||||
return createPlaybackSourceMetadata({
|
||||
url,
|
||||
mimeType: DASH_MIME_TYPE,
|
||||
player: this.config.player,
|
||||
});
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): void {
|
||||
this.operationChain = this.operationChain.then(operation, operation);
|
||||
}
|
||||
|
||||
private isStale(generation: number): boolean {
|
||||
return this.destroyed || generation !== this.generation;
|
||||
}
|
||||
|
||||
private emitIfCurrent(
|
||||
generation: number,
|
||||
issue: PlaybackDiagnostic
|
||||
): void {
|
||||
if (!this.isStale(generation)) {
|
||||
this.config.emitPlaybackIssue(issue);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyRefresh(): void {
|
||||
for (const listener of [...this.refreshListeners]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asShakaError(error: unknown): Partial<ShakaErrorLike> | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = error as Partial<ShakaErrorLike>;
|
||||
return typeof candidate.code === 'number' ||
|
||||
typeof candidate.category === 'number'
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return typeof error === 'string' ? error : String(error);
|
||||
}
|
||||
@@ -225,6 +225,7 @@ export class WebPlayerViewComponent {
|
||||
'',
|
||||
},
|
||||
radio: 'false',
|
||||
drm: playback.drm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,6 +392,8 @@ export class WebPlayerViewComponent {
|
||||
return 'mpegts.js';
|
||||
case 'native':
|
||||
return 'Native media element';
|
||||
case 'shaka':
|
||||
return 'Shaka Player';
|
||||
case 'source':
|
||||
return 'Stream metadata';
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Exits document fullscreen only when the given surface owns it. Shared by
|
||||
* player components whose shared-controls mode uses DOM-based fullscreen.
|
||||
*/
|
||||
export function exitOwnedFullscreen(
|
||||
sharedControls: boolean,
|
||||
surface: HTMLElement | undefined,
|
||||
reportError: (error: unknown) => void
|
||||
): void {
|
||||
if (
|
||||
!sharedControls ||
|
||||
document.fullscreenElement !== surface ||
|
||||
typeof document.exitFullscreen !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void Promise.resolve(document.exitFullscreen()).catch(reportError);
|
||||
} catch (error: unknown) {
|
||||
reportError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Helpers for driving the native `<video>` element source list. */
|
||||
|
||||
export function clearNativeVideoSources(element: HTMLVideoElement): void {
|
||||
element.removeAttribute('src');
|
||||
element.replaceChildren();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the element's sources with a single `<source>` without loading it;
|
||||
* callers bind their controls source first, then call `element.load()`.
|
||||
*/
|
||||
export function setNativeVideoSource(
|
||||
element: HTMLVideoElement,
|
||||
url: string,
|
||||
type: string
|
||||
): void {
|
||||
clearNativeVideoSources(element);
|
||||
const source = document.createElement('source');
|
||||
source.src = url;
|
||||
source.type = type;
|
||||
element.appendChild(source);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { PlayerTrack } from '../player-controls/player-controls.model';
|
||||
import type {
|
||||
ShakaPlayerLike,
|
||||
ShakaTextTrackLike,
|
||||
} from '../shaka-engine/shaka-module.types';
|
||||
import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
|
||||
|
||||
export interface WebVideoShakaControlsConfig {
|
||||
showCaptions: () => boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const SUBTITLES_OFF = -1;
|
||||
|
||||
/**
|
||||
* Maps a {@link ShakaVideoSession} onto the shared-controls track contract,
|
||||
* mirroring {@link WebVideoHlsControls}. Track ids are indexes into the
|
||||
* player's current track arrays; `-1` means subtitles explicitly off.
|
||||
*/
|
||||
export class WebVideoShakaControls {
|
||||
private session: ShakaVideoSession | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private subtitleOverride: number | null = null;
|
||||
|
||||
constructor(private readonly config: WebVideoShakaControlsConfig) {}
|
||||
|
||||
bind(session: ShakaVideoSession): void {
|
||||
this.clear();
|
||||
this.session = session;
|
||||
this.unsubscribe = session.subscribe(() => {
|
||||
this.applyCaptionState();
|
||||
this.config.refresh();
|
||||
});
|
||||
this.applyCaptionState();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.session = null;
|
||||
this.subtitleOverride = null;
|
||||
}
|
||||
|
||||
refreshInputs(): void {
|
||||
this.applyCaptionState();
|
||||
}
|
||||
|
||||
getAudioTracks(): PlayerTrack[] {
|
||||
return (this.getPlayer()?.getAudioTracks() ?? []).map(
|
||||
(track, index) => ({
|
||||
id: index,
|
||||
label:
|
||||
track.label ||
|
||||
track.language ||
|
||||
`Audio ${index + 1}`,
|
||||
selected: track.active,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setAudioTrack(id: number): void {
|
||||
const player = this.getPlayer();
|
||||
if (!player || !Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const track = player.getAudioTracks()[id];
|
||||
if (track) {
|
||||
player.selectAudioTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
getSubtitleTracks(): PlayerTrack[] {
|
||||
const player = this.getPlayer();
|
||||
if (!player) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return player.getTextTracks().map((track, index) => ({
|
||||
id: index,
|
||||
label: formatTextTrackLabel(track, index),
|
||||
selected: track.active,
|
||||
}));
|
||||
}
|
||||
|
||||
setSubtitleTrack(id: number): void {
|
||||
const player = this.getPlayer();
|
||||
if (!player || !Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Shaka 5 model: selecting a track shows it, null turns text off.
|
||||
if (id === SUBTITLES_OFF) {
|
||||
this.subtitleOverride = SUBTITLES_OFF;
|
||||
player.selectTextTrack(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const track = player.getTextTracks()[id];
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subtitleOverride = id;
|
||||
player.selectTextTrack(track);
|
||||
}
|
||||
|
||||
private applyCaptionState(): void {
|
||||
const player = this.getPlayer();
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Without an explicit user choice, keep manifest-auto-selected text
|
||||
// hidden while the captions preference is off (HLS parity). A user
|
||||
// selection (subtitleOverride) always wins.
|
||||
if (
|
||||
this.subtitleOverride === null &&
|
||||
!this.config.showCaptions() &&
|
||||
player.getTextTracks().some((track) => track.active)
|
||||
) {
|
||||
player.selectTextTrack(null);
|
||||
}
|
||||
}
|
||||
|
||||
private getPlayer(): ShakaPlayerLike | null {
|
||||
return this.session?.getPlayer() ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTextTrackLabel(
|
||||
track: ShakaTextTrackLike,
|
||||
index: number
|
||||
): string {
|
||||
return track.label || track.language || `Subtitle ${index + 1}`;
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import type Hls from 'hls.js';
|
||||
import type { PlayerTrack } from '../player-controls/player-controls.model';
|
||||
import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
|
||||
import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
|
||||
import { WebVideoHlsControls } from './web-video-hls-controls';
|
||||
import { WebVideoNativeTextTracks } from './web-video-native-text-tracks';
|
||||
import { WebVideoShakaControls } from './web-video-shaka-controls';
|
||||
|
||||
export type WebVideoControlsSource =
|
||||
| { kind: 'native' }
|
||||
| { kind: 'mpegts' }
|
||||
| { kind: 'hls'; hls: Hls };
|
||||
| { kind: 'hls'; hls: Hls }
|
||||
| { kind: 'shaka'; session: ShakaVideoSession };
|
||||
|
||||
export interface WebVideoSourceControlsBridgeConfig {
|
||||
video: HTMLVideoElement;
|
||||
@@ -19,6 +22,7 @@ export interface WebVideoSourceControlsBridgeConfig {
|
||||
export class WebVideoSourceControlsBridge {
|
||||
private readonly config: WebVideoSourceControlsBridgeConfig;
|
||||
private readonly hlsControls: WebVideoHlsControls;
|
||||
private readonly shakaControls: WebVideoShakaControls;
|
||||
private readonly nativeTextTracks: WebVideoNativeTextTracks;
|
||||
private source: WebVideoControlsSource | null = null;
|
||||
private attached = false;
|
||||
@@ -31,6 +35,10 @@ export class WebVideoSourceControlsBridge {
|
||||
showCaptions: config.showCaptions,
|
||||
refresh,
|
||||
});
|
||||
this.shakaControls = new WebVideoShakaControls({
|
||||
showCaptions: config.showCaptions,
|
||||
refresh,
|
||||
});
|
||||
this.nativeTextTracks = new WebVideoNativeTextTracks({
|
||||
video: config.video,
|
||||
showCaptions: config.showCaptions,
|
||||
@@ -63,6 +71,8 @@ export class WebVideoSourceControlsBridge {
|
||||
this.source = source;
|
||||
if (source.kind === 'hls') {
|
||||
this.hlsControls.bind(source.hls);
|
||||
} else if (source.kind === 'shaka') {
|
||||
this.shakaControls.bind(source.session);
|
||||
} else {
|
||||
this.nativeTextTracks.bind();
|
||||
}
|
||||
@@ -76,6 +86,8 @@ export class WebVideoSourceControlsBridge {
|
||||
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.refreshInputs();
|
||||
} else if (this.source?.kind === 'shaka') {
|
||||
this.shakaControls.refreshInputs();
|
||||
} else if (this.source) {
|
||||
this.nativeTextTracks.refreshInputs();
|
||||
}
|
||||
@@ -130,14 +142,20 @@ export class WebVideoSourceControlsBridge {
|
||||
}
|
||||
|
||||
private getAudioTracks(): PlayerTrack[] {
|
||||
return this.source?.kind === 'hls'
|
||||
? this.hlsControls.getAudioTracks()
|
||||
: [];
|
||||
if (this.source?.kind === 'hls') {
|
||||
return this.hlsControls.getAudioTracks();
|
||||
}
|
||||
if (this.source?.kind === 'shaka') {
|
||||
return this.shakaControls.getAudioTracks();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private setAudioTrack(id: number): void {
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.setAudioTrack(id);
|
||||
} else if (this.source?.kind === 'shaka') {
|
||||
this.shakaControls.setAudioTrack(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +163,17 @@ export class WebVideoSourceControlsBridge {
|
||||
if (this.source?.kind === 'hls') {
|
||||
return this.hlsControls.getSubtitleTracks();
|
||||
}
|
||||
if (this.source?.kind === 'shaka') {
|
||||
return this.shakaControls.getSubtitleTracks();
|
||||
}
|
||||
return this.source ? this.nativeTextTracks.getSubtitleTracks() : [];
|
||||
}
|
||||
|
||||
private setSubtitleTrack(id: number): void {
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.setSubtitleTrack(id);
|
||||
} else if (this.source?.kind === 'shaka') {
|
||||
this.shakaControls.setSubtitleTrack(id);
|
||||
} else if (this.source) {
|
||||
this.nativeTextTracks.setSubtitleTrack(id);
|
||||
}
|
||||
@@ -159,6 +182,8 @@ export class WebVideoSourceControlsBridge {
|
||||
private clearActiveSource(): void {
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.clear();
|
||||
} else if (this.source?.kind === 'shaka') {
|
||||
this.shakaControls.clear();
|
||||
} else if (this.source) {
|
||||
this.nativeTextTracks.clear();
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"ngx-skeleton-loader": "11.3.0",
|
||||
"rxjs": "7.8.2",
|
||||
"saxes": "6.0.0",
|
||||
"shaka-player": "5.2.1",
|
||||
"uuid": "9.0.0",
|
||||
"video.js": "8.23.4",
|
||||
"videojs-contrib-quality-levels": "4.1.0",
|
||||
|
||||
Generated
+132
-13
@@ -149,6 +149,9 @@ importers:
|
||||
saxes:
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0
|
||||
shaka-player:
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
uuid:
|
||||
specifier: 9.0.0
|
||||
version: 9.0.0
|
||||
@@ -398,7 +401,7 @@ importers:
|
||||
version: 30.2.0
|
||||
jest-preset-angular:
|
||||
specifier: ~15.0.0
|
||||
version: 15.0.3(75c2f4d3e02d43367177c3c83cdeef78)
|
||||
version: 15.0.3(c17320d959ee89222a5c83bff087787c)
|
||||
jest-util:
|
||||
specifier: ^30.0.2
|
||||
version: 30.2.0
|
||||
@@ -431,7 +434,7 @@ importers:
|
||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
||||
ts-jest:
|
||||
specifier: ^29.4.5
|
||||
version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.28.1)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
version: 29.4.6(@babel/core@7.29.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
ts-node:
|
||||
specifier: 10.9.2
|
||||
version: 10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)
|
||||
@@ -10542,6 +10545,10 @@ packages:
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
shaka-player@5.2.1:
|
||||
resolution: {integrity: sha512-Sj8Kg94Ws8hUxzjHnC570o1jQcZwB7OxIwi25vLI3mOVmL8xpso3HyIqM7hjbgAhsOgdFojpJYwtcvR/t0a9vA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
shallow-clone@3.0.1:
|
||||
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -12925,6 +12932,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -12935,6 +12948,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -12945,6 +12964,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -12955,6 +12980,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -12975,6 +13006,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -12985,6 +13022,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -12995,6 +13038,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -13015,6 +13064,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -13025,6 +13080,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -13035,6 +13096,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -13045,6 +13112,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -13055,6 +13128,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -13065,6 +13144,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -13075,6 +13160,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -13085,6 +13176,12 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
optional: true
|
||||
|
||||
'@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -17677,13 +17774,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-jest@30.2.0(@babel/core@7.29.0):
|
||||
babel-jest@30.2.0(@babel/core@7.29.7):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/core': 7.29.7
|
||||
'@jest/transform': 30.2.0
|
||||
'@types/babel__core': 7.20.5
|
||||
babel-plugin-istanbul: 7.0.1
|
||||
babel-preset-jest: 30.2.0(@babel/core@7.29.0)
|
||||
babel-preset-jest: 30.2.0(@babel/core@7.29.7)
|
||||
chalk: 4.1.2
|
||||
graceful-fs: 4.2.11
|
||||
slash: 3.0.0
|
||||
@@ -17796,17 +17893,37 @@ snapshots:
|
||||
'@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0)
|
||||
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0)
|
||||
|
||||
babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7)
|
||||
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7)
|
||||
optional: true
|
||||
|
||||
babel-preset-jest@30.2.0(@babel/core@7.28.5):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
babel-plugin-jest-hoist: 30.2.0
|
||||
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5)
|
||||
|
||||
babel-preset-jest@30.2.0(@babel/core@7.29.0):
|
||||
babel-preset-jest@30.2.0(@babel/core@7.29.7):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/core': 7.29.7
|
||||
babel-plugin-jest-hoist: 30.2.0
|
||||
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0)
|
||||
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7)
|
||||
optional: true
|
||||
|
||||
bail@2.0.2: {}
|
||||
@@ -21069,7 +21186,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
jest-resolve: 30.2.0
|
||||
|
||||
jest-preset-angular@15.0.3(75c2f4d3e02d43367177c3c83cdeef78):
|
||||
jest-preset-angular@15.0.3(c17320d959ee89222a5c83bff087787c):
|
||||
dependencies:
|
||||
'@angular/compiler-cli': 21.2.17(@angular/compiler@21.2.17)(typescript@5.9.3)
|
||||
'@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)
|
||||
@@ -21082,7 +21199,7 @@ snapshots:
|
||||
jest-util: 30.2.0
|
||||
jsdom: 26.1.0
|
||||
pretty-format: 30.2.0
|
||||
ts-jest: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.28.1)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
ts-jest: 29.4.6(@babel/core@7.29.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
optionalDependencies:
|
||||
esbuild: 0.28.1
|
||||
@@ -24272,6 +24389,8 @@ snapshots:
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shaka-player@5.2.1: {}
|
||||
|
||||
shallow-clone@3.0.1:
|
||||
dependencies:
|
||||
kind-of: 6.0.3
|
||||
@@ -24978,7 +25097,7 @@ snapshots:
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.28.1)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3):
|
||||
ts-jest@29.4.6(@babel/core@7.29.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3):
|
||||
dependencies:
|
||||
bs-logger: 0.2.6
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
@@ -24992,10 +25111,10 @@ snapshots:
|
||||
typescript: 5.9.3
|
||||
yargs-parser: 21.1.1
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/core': 7.29.7
|
||||
'@jest/transform': 30.2.0
|
||||
'@jest/types': 30.2.0
|
||||
babel-jest: 30.2.0(@babel/core@7.29.0)
|
||||
babel-jest: 30.2.0(@babel/core@7.29.7)
|
||||
esbuild: 0.28.1
|
||||
jest-util: 30.2.0
|
||||
|
||||
|
||||
Reference in new issue
Block a user