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:
4grayandClaude Fable 5 committed 2026-07-18 19:16:37 +02:00
1 parent e9d336ee7a
commit b1e9aeaf7b
26 files changed
+1775 -137

No files matched your search

+1
View File
@@ -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',
+91
View File
@@ -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;
+1
View File
@@ -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')
)
);
}
@@ -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,
@@ -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();
}
+1
View File
@@ -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",
+132 -13
View File
@@ -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