feat(m3u): DASH + ClearKey playback via Shaka Player (#1225)

* feat(m3u): extract ClearKey DRM from #KODIPROP playlist lines

Adds the typed ChannelDrm model (shared interfaces) and a KODIPROP
post-processing step in createPlaylistObject() — the single funnel for all
four playlist import paths. Parses inputstream.adaptive.license_type,
license_key and drm_legacy; ClearKey keys accepted as kid:key hex pairs,
W3C ClearKey license JSON, or a plain kid→key JSON map. Unsupported license
types (Widevine/PlayReady/license URLs) are preserved with supported=false
so playback can surface a DRM diagnostic instead of failing silently.
Also adds isDashStreamUrl/isDashChannel helpers for DASH routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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>

* feat(m3u): route DASH channels to the inline Shaka-capable player

DASH (.mpd) channels always play in a built-in web engine (radio
precedent): external MPV/VLC cannot receive KODIPROP ClearKey
configuration (VLC upstream #29465) and Video.js has no DASH bridge yet.

- shouldShowInlinePlayer() bypasses the external-player setting for DASH
- new shouldAutoLaunchExternalPlayer() guard consolidates the MPV/VLC
  auto-launch conditions in the m3u-state effects (incl. catch-up path)
- the M3U page overrides the player for DASH channels: ArtPlayer stays
  ArtPlayer, everything else falls back to the HTML5 player
- ChannelDrm is passed through ResolvedPortalPlayback into the synthetic
  player-view channel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): add offline DASH ClearKey fixtures and e2e coverage

Fixtures (apps/web-e2e/src/fixtures/dash/): ~4s VP9+Opus DASH, clear and
CENC-encrypted variants with fixed synthetic ClearKey credentials.
Content synthesized by ffmpeg; encryption done by Shaka Packager because
ffmpeg's mp4 muxer writes senc-only metadata (Chromium needs saiz/saio)
and cannot produce the subsample encryption the VP9 CENC binding
requires. Generation script + README document regeneration.

web-e2e (Chromium): import an M3U with KODIPROP ClearKey via raw text,
verify encrypted and clear DASH actually play (currentTime advances, no
diagnostic banner) and that an unsupported license type (Widevine)
surfaces the DRM diagnostic. Fixtures are served through Playwright route
interception with HTTP Range support; the Angular service worker is
blocked since SW-routed requests bypass interception.

electron-backend-e2e: the same happy path + negative against a local
Range-aware fixture server — the automated proof that ClearKey EME works
in the real Electron runtime (file:// secure context).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: document DASH + ClearKey playback architecture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pwa): extract KODIPROP DRM on the web-backend /parse import path

The web-backend keeps its own playlist builder for the PWA URL-import
path, so the shared createPlaylistObject() DRM hook never ran there and
encrypted DASH channels imported by URL reached Shaka without keys.
Apply extractDrmFromRaw() in that builder too and cover the path with a
regression test.

Addresses Codex review on PR #1225.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Two review findings on the ShakaVideoSession lifecycle:

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(m3u): unify DASH URL detection with playback extension normalization

isDashStreamUrl() used the simpler getStreamExtensionFromUrl(), so URLs
the player engines classify as DASH (stream.MPD, ?ext=mpd, ?format=mpd)
were not routed to the Shaka-capable inline player and lost their
ClearKey metadata with Video.js or external players configured
(Codex P2). The normalized getPlaybackMediaExtensionFromUrl() now lives
in @iptvnator/shared/m3u-utils (re-exported unchanged from the playback
lib) and both routing and engine selection share it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(lint): satisfy CI lint and CodeQL in DASH support files

- replace shell-built tar/npm commands with execFileSync arg arrays in
  the fixture generator (CodeQL: uncontrolled shell command)
- give jest stub methods explicit bodies (no-empty-function)
- compact the diagnostic label switches in WebPlayerViewComponent to
  stay under the max-lines budget

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): tear down the Shaka engine on critical error events too

A non-recoverable Shaka error emitted after a successful load left the
dead engine attached to the media element and exposed to the
shared-controls bridge (Greptile P1, round 2). Critical error events now
destroy the player right after the diagnostic is emitted, matching the
load-failure path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(m3u): honor DASH catch-up URLs and drop unusable DRM fallbacks

Two Codex round-2 findings:

- The inline-playback DASH gate only examined the channel URL, while the
  external-player guard checks the resolved catch-up URL — a replay that
  resolves to an .mpd manifest with MPV/VLC configured ended up with no
  player at all. The gate now uses the effective playback URL
  (activePlaybackUrl ?? channel.url).
- The unsupported-DRM diagnostic advertised MPV/VLC fallback actions,
  but external players cannot receive the KODIPROP license config either
  — the diagnostic no longer recommends them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): suppress unusable external fallback for ClearKey DRM failures

Runtime DRM errors on channels that carry KODIPROP ClearKey config (wrong
or rotated keys) advertised MPV/VLC fallback actions, but external
players never receive the license config — the fallback could only fail
differently. DRM-classified diagnostics from such channels no longer
recommend external players; clear channels keep the hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(m3u): symmetric DASH inline gate and lazy DRM for pre-upgrade playlists

- The inline DASH gate is now true when either the channel or the
  resolved catch-up URL is DASH, mirroring the external-player guard —
  a .mpd channel whose catch-up resolves to .m3u8 no longer ends up
  with no player at all.
- Playlists imported before the DRM feature carry no drm field, but the
  raw KODIPROP block survived in the stored items; the M3U page now
  falls back to extractDrmFromRaw(channel.raw) at playback time, so
  encrypted channels work without a re-import (Channel gains raw?).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: sync the DASH/Shaka contract across agent docs

Mirrors the DASH/Shaka source-engine contract into AGENTS.md and adds
Shaka to the shared web-video bridge descriptions in CLAUDE.md and the
player-controls contract; documents the lazy raw-KODIPROP DRM fallback
for pre-upgrade playlists in the M3U architecture doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): reset the media element for rejected DRM and widen ClearKey fallback suppression

- Switching from a playing stream to an unsupported-DRM DASH channel
  loads no new source, but play() still ran and the un-loaded element
  could resume the previous stream underneath the diagnostic banner.
  The HTML5 player now resets the element instead of playing.
- Any inline failure on a KODIPROP ClearKey channel (manifest, codec,
  media, network — not just DRM-category errors) is unsolvable in
  MPV/VLC, which never receive the license config; the external
  fallback hint is now suppressed for all diagnostics of such channels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): restore suppressed DASH captions when the preference re-enables

The Shaka bridge dropped the auto-selected text track with
selectTextTrack(null) when showCaptions was off, but did not remember it
— re-enabling the preference mid-session left captions permanently off
(HLS/native bridges already restore). The session now remembers the
suppressed track id and reselects it via the bridge's caption-state pass;
suppression is also skipped when no track is active. Covered by session
and new WebVideoShakaControls specs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: retrigger CI

GitHub Actions created no check suites for the last three pushes to this
branch (third-party apps received the webhooks); an empty commit re-fires
the push and pull_request events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(playback): split oversized Shaka session and HTML5 spec files

CI lint enforces max-lines 400: extract ShakaTextTrackSuppression and the
shaka-error helpers out of ShakaVideoSession, and move the DASH-specific
HTML5 player test into its own spec. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: allow manual dispatch of the cross-platform E2E workflow

GitHub stopped delivering push/pull_request events for this branch;
workflow_dispatch provides a manual escape hatch (CI and build-and-make
already have one).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-24 20:31:18 +02:00
1 parent 6dc8a10d52
commit bd07e17857
70 files changed
+4185 -462

No files matched your search

+98 -2
View File
@@ -1,6 +1,9 @@
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { Channel, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
import { EpgActions } from './actions';
import { buildExternalPlayerPayload } from './external-player-payload.util';
import {
buildExternalPlayerPayload,
shouldAutoLaunchExternalPlayer,
} from './external-player-payload.util';
import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util';
describe('buildExternalPlayerPayload', () => {
@@ -44,6 +47,99 @@ describe('buildExternalPlayerPayload', () => {
origin: 'https://origin.example.com',
});
});
describe('shouldAutoLaunchExternalPlayer', () => {
const mpvSettings = { player: VideoPlayer.MPV };
it('launches the configured external player for regular channels', () => {
expect(
shouldAutoLaunchExternalPlayer(
mpvSettings,
true,
activeChannel,
VideoPlayer.MPV
)
).toBe(true);
expect(
shouldAutoLaunchExternalPlayer(
mpvSettings,
true,
activeChannel,
VideoPlayer.VLC
)
).toBe(false);
});
it('never launches external players for DASH (.mpd) channels', () => {
const dashChannel: Channel = {
...activeChannel,
url: 'https://streams.example.com/live.mpd',
};
expect(
shouldAutoLaunchExternalPlayer(
mpvSettings,
true,
dashChannel,
VideoPlayer.MPV
)
).toBe(false);
expect(
shouldAutoLaunchExternalPlayer(
{ player: VideoPlayer.VLC },
true,
dashChannel,
VideoPlayer.VLC
)
).toBe(false);
});
it('never launches external players for radio channels', () => {
expect(
shouldAutoLaunchExternalPlayer(
mpvSettings,
true,
{ ...activeChannel, radio: 'true' },
VideoPlayer.MPV
)
).toBe(false);
});
it('respects the double-click setting and missing settings', () => {
expect(
shouldAutoLaunchExternalPlayer(
{ ...mpvSettings, openStreamOnDoubleClick: true },
undefined,
activeChannel,
VideoPlayer.MPV
)
).toBe(false);
expect(
shouldAutoLaunchExternalPlayer(
{ ...mpvSettings, openStreamOnDoubleClick: true },
true,
activeChannel,
VideoPlayer.MPV
)
).toBe(true);
expect(
shouldAutoLaunchExternalPlayer(
null,
true,
activeChannel,
VideoPlayer.MPV
)
).toBe(false);
expect(
shouldAutoLaunchExternalPlayer(
{},
true,
activeChannel,
VideoPlayer.MPV
)
).toBe(false);
});
});
});
describe('resolveActiveEpgProgramAction', () => {
+27 -17
View File
@@ -2,7 +2,11 @@ import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { EpgService } from '@iptvnator/epg/data-access';
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
import {
isDashChannel,
isDashStreamUrl,
normalizeEpgUrls,
} from '@iptvnator/shared/m3u-utils';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -45,7 +49,10 @@ import {
selectFavorites,
} from './selectors';
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
import { buildExternalPlayerPayload } from './external-player-payload.util';
import {
buildExternalPlayerPayload,
shouldAutoLaunchExternalPlayer,
} from './external-player-payload.util';
import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util';
import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util';
@@ -189,16 +196,13 @@ export class PlaylistEffects {
firstValueFrom(this.storage.get(STORE_KEY.Settings)).then(
(settings: any) => {
const shouldOpenExternalPlayer =
!settings?.openStreamOnDoubleClick ||
action.startPlayback === true;
if (
settings &&
Object.keys(settings).length > 0 &&
shouldOpenExternalPlayer &&
settings.player === VideoPlayer.MPV &&
channel.radio !== 'true'
shouldAutoLaunchExternalPlayer(
settings,
action.startPlayback,
channel,
VideoPlayer.MPV
)
) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: channel.url,
@@ -208,12 +212,13 @@ export class PlaylistEffects {
origin: channel.http.origin,
});
} else if (
settings &&
Object.keys(settings).length > 0 &&
shouldOpenExternalPlayer &&
settings.player === VideoPlayer.VLC &&
channel.radio !== 'true'
)
shouldAutoLaunchExternalPlayer(
settings,
action.startPlayback,
channel,
VideoPlayer.VLC
)
) {
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: channel.url,
title: channel.name ?? '',
@@ -221,6 +226,7 @@ export class PlaylistEffects {
referer: channel.http.referrer,
origin: channel.http.origin,
});
}
}
);
@@ -253,6 +259,10 @@ export class PlaylistEffects {
playbackUrl: string,
activeChannel: Channel | undefined | null
): Promise<void> {
if (isDashStreamUrl(playbackUrl) || isDashChannel(activeChannel)) {
return;
}
const payload = buildExternalPlayerPayload(activeChannel, playbackUrl);
if (!payload) {
return;
@@ -1,4 +1,34 @@
import { Channel } from '@iptvnator/shared/interfaces';
import { Channel, VideoPlayer } from '@iptvnator/shared/interfaces';
import { isDashChannel } from '@iptvnator/shared/m3u-utils';
/**
* Decides whether activating a channel should auto-launch the configured
* external player. Radio channels use the inline audio player; DASH (`.mpd`)
* channels use the inline Shaka engine because MPV/VLC cannot receive the
* KODIPROP ClearKey configuration.
*/
export function shouldAutoLaunchExternalPlayer(
settings:
| { player?: VideoPlayer; openStreamOnDoubleClick?: boolean }
| null
| undefined,
startPlayback: boolean | undefined,
channel: Channel,
player: VideoPlayer.MPV | VideoPlayer.VLC
): boolean {
if (!settings || Object.keys(settings).length === 0) {
return false;
}
const startRequested =
!settings.openStreamOnDoubleClick || startPlayback === true;
return (
startRequested &&
settings.player === player &&
channel.radio !== 'true' &&
!isDashChannel(channel)
);
}
export function buildExternalPlayerPayload(
activeChannel: Channel | undefined | null,
@@ -76,7 +76,11 @@
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
[title]="inlinePlayerTitle()"
[playback]="embeddedPlayback()"
[playerOverride]="playerSettings.player ?? null"
[playerOverride]="
activeChannelIsDash()
? dashPlayerOverride()
: (playerSettings.player ?? null)
"
[volume]="volume()"
[showCaptions]="!!playerSettings.showCaptions"
(externalFallbackRequested)="
@@ -627,6 +627,136 @@ describe('VideoPlayerComponent', () => {
).toBe(false);
});
it('keeps DASH channels inline on the HTML5 player even when MPV is configured', () => {
syncStoreState({
...sampleChannel,
url: 'http://localhost/live.mpd',
} as Channel);
player.set(VideoPlayer.MPV);
fixture.detectChanges();
const playerView = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
);
expect(playerView).not.toBeNull();
const stub =
playerView.componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled();
});
it('routes catch-up playback that resolves to a DASH URL inline as well', () => {
syncStoreState(sampleChannel);
activePlaybackUrl.set('http://localhost/archive/replay.mpd');
player.set(VideoPlayer.MPV);
fixture.detectChanges();
const playerView = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
);
expect(playerView).not.toBeNull();
const stub =
playerView.componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
});
it('keeps a DASH channel inline when its catch-up resolves to a non-DASH URL', () => {
syncStoreState({
...sampleChannel,
url: 'http://localhost/live.mpd',
} as Channel);
activePlaybackUrl.set('http://localhost/archive/replay.m3u8');
player.set(VideoPlayer.MPV);
fixture.detectChanges();
// The external-player guard declines DASH channels, so the inline
// player must stay — otherwise the session has no player at all.
expect(
fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
)
).not.toBeNull();
});
it('extracts DRM lazily from raw KODIPROP for pre-upgrade playlists', () => {
const kid = '00112233445566778899aabbccddeeff';
const key = 'ffeeddccbbaa99887766554433221100';
syncStoreState({
...sampleChannel,
url: 'http://localhost/enc.mpd',
raw: [
'#EXTINF:-1,Encrypted',
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${kid}:${key}`,
'http://localhost/enc.mpd',
].join('\r\n'),
} as Channel);
player.set(VideoPlayer.Html5Player);
fixture.detectChanges();
const stub = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(stub.playback()).toEqual(
expect.objectContaining({
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: { [kid]: key },
},
})
);
});
it('keeps ArtPlayer for DASH channels and forwards the ClearKey DRM config', () => {
const drm = {
licenseType: 'clearkey',
supported: true,
clearKeys: { '11223344556677889900aabbccddeeff': 'f'.repeat(32) },
};
syncStoreState({
...sampleChannel,
url: 'http://localhost/live.mpd',
drm,
} as Channel);
player.set(VideoPlayer.ArtPlayer);
fixture.detectChanges();
const stub = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.ArtPlayer);
expect(stub.playback()).toEqual(expect.objectContaining({ drm }));
});
it('routes Video.js users to the HTML5 player only for DASH channels', () => {
syncStoreState({
...sampleChannel,
url: 'http://localhost/live.mpd',
} as Channel);
player.set(VideoPlayer.VideoJs);
fixture.detectChanges();
let stub = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
syncStoreState(sampleChannel);
fixture.detectChanges();
stub = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.VideoJs);
});
it('passes remote volume changes to the radio audio player', () => {
const radioChannel = {
...sampleChannel,
@@ -24,6 +24,9 @@ import { ResizableDirective } from '@iptvnator/ui/components';
import {
applyChannelNameStrip,
getM3uArchiveDays,
extractDrmFromRaw,
isDashChannel,
isDashStreamUrl,
isM3uCatchupPlaybackSupported,
} from '@iptvnator/shared/m3u-utils';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
@@ -173,6 +176,28 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly archivePlaybackAvailable = computed(() =>
isM3uCatchupPlaybackSupported(this.activeChannel())
);
/**
* DASH (.mpd) playback always runs inline via the Shaka engine. True when
* either the channel itself or the resolved catch-up URL is DASH —
* mirroring the external-player guard in the m3u-state effects, so a
* DASH-flavored session can never end up with no player at all.
*/
readonly activeChannelIsDash = computed(
() =>
isDashStreamUrl(this.activePlaybackUrl() ?? undefined) ||
isDashChannel(this.activeChannel())
);
/**
* Player forced for DASH channels: ArtPlayer keeps ArtPlayer (it has a
* Shaka source engine); every other choice — Video.js (no DASH bridge),
* embedded/external MPV and VLC (no KODIPROP ClearKey support) — falls
* back to the HTML5 player.
*/
readonly dashPlayerOverride = computed<VideoPlayer>(() =>
this.settingsStore.player() === VideoPlayer.ArtPlayer
? VideoPlayer.ArtPlayer
: VideoPlayer.Html5Player
);
/** Full multi-day programme window for the active channel (timeline). */
readonly epgPrograms = toSignal(this.epgService.currentEpgPrograms$, {
initialValue: [] as EpgProgram[],
@@ -280,6 +305,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
userAgent: http['user-agent'] || undefined,
referer: http.referrer || undefined,
origin: http.origin || undefined,
// Playlists imported before the DRM feature carry no drm field
// yet, but their raw KODIPROP block survived in the stored items
// — extract lazily so they work without a re-import.
drm: playbackTarget.drm ?? extractDrmFromRaw(playbackTarget.raw),
};
});
readonly sidebarStorageKey = computed(() =>
@@ -992,6 +1021,13 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
return false;
}
// DASH playback bypasses the external-player setting (radio
// precedent): MPV/VLC cannot receive the KODIPROP ClearKey
// configuration. Checked on the effective (possibly catch-up) URL.
if (this.activeChannelIsDash()) {
return true;
}
return !this.isExternalPlayer(this.playerSettings.player);
}
+1
View File
@@ -1,3 +1,4 @@
export * from './lib/channel-drm.interface';
export * from './lib/channel.interface';
export * from './lib/channel.model';
export * from './lib/dev-logger.util';
@@ -0,0 +1,18 @@
/**
* DRM configuration extracted from `#KODIPROP:inputstream.adaptive.*` lines of
* an M3U playlist entry. Only ClearKey is playable in-app; other license types
* are preserved so playback can surface a meaningful DRM diagnostic instead of
* failing silently.
*/
export interface ChannelDrmClearKeys {
/** Key id (32 lowercase hex chars) mapped to its key (32 lowercase hex chars). */
[kidHex: string]: string;
}
export interface ChannelDrm {
/** Normalized license type, e.g. `clearkey` or `com.widevine.alpha`. */
licenseType: string;
/** True only for ClearKey entries with at least one successfully parsed key. */
supported: boolean;
clearKeys?: ChannelDrmClearKeys;
}
@@ -1,3 +1,5 @@
import { ChannelDrm } from './channel-drm.interface';
/**
* Represents channel object
* TODO: define channel interface in iptv-parser library
@@ -29,4 +31,7 @@ export interface Channel {
origin: string;
};
radio: string;
drm?: ChannelDrm;
/** Raw M3U block of the entry as preserved by the playlist parser. */
raw?: string;
}
@@ -1,3 +1,5 @@
import { ChannelDrm } from './channel-drm.interface';
export interface ParsedPlaylist {
header: {
attrs: Record<string, string | undefined>;
@@ -32,4 +34,5 @@ export interface ParsedPlaylistItem {
};
timeshift?: string;
radio?: string;
drm?: ChannelDrm;
}
@@ -1,3 +1,4 @@
import { ChannelDrm } from './channel-drm.interface';
import { PlaybackPositionData } from './playback-position.interface';
export interface PlayerContentInfo extends Omit<
@@ -18,4 +19,5 @@ export interface ResolvedPortalPlayback {
userAgent?: string;
referer?: string;
origin?: string;
drm?: ChannelDrm;
}
+3
View File
@@ -1,3 +1,6 @@
export * from './lib/dash.utils';
export * from './lib/kodiprop.utils';
export * from './lib/playback-media-extension.util';
export * from './lib/playlist.utils';
export * from './lib/catchup.utils';
export * from './lib/strip-country-prefix.util';
@@ -0,0 +1,36 @@
import { isDashChannel, isDashStreamUrl } from './dash.utils';
describe('dash.utils', () => {
it.each([
'https://example.com/live/stream.mpd',
'https://example.com/live/stream.MPD',
'https://example.com/live/stream.mpd?token=abc#frag',
'https://example.com/play?extension=mpd',
'https://example.com/play?ext=mpd',
'https://example.com/play?format=mpd',
])('detects %s as DASH', (url) => {
expect(isDashStreamUrl(url)).toBe(true);
});
it.each([
'https://example.com/live/playlist.m3u8',
'https://example.com/live/stream.ts',
'https://example.com/live.php?id=7',
'https://example.com/movie.mp4',
'',
undefined,
])('does not flag %s as DASH', (url) => {
expect(isDashStreamUrl(url as string | undefined)).toBe(false);
});
it('detects DASH channels by their stream URL', () => {
expect(
isDashChannel({ url: 'https://example.com/enc.mpd' })
).toBe(true);
expect(
isDashChannel({ url: 'https://example.com/live.m3u8' })
).toBe(false);
expect(isDashChannel(null)).toBe(false);
expect(isDashChannel(undefined)).toBe(false);
});
});
@@ -0,0 +1,18 @@
import { Channel } from '@iptvnator/shared/interfaces';
import { getPlaybackMediaExtensionFromUrl } from './playback-media-extension.util';
/**
* DASH (`.mpd`) detection for M3U channels. DASH streams must always play in
* a Shaka-capable built-in web engine: external MPV/VLC cannot receive the
* KODIPROP ClearKey configuration, and Video.js has no DASH bridge yet.
*
* Uses the same normalized extension detection as the player engines, so
* routing and engine selection always agree (`stream.MPD`, `?format=mpd`,
* `?ext=mpd` and friends included).
*/
export const isDashStreamUrl = (url: string | undefined): boolean =>
!!url && getPlaybackMediaExtensionFromUrl(url) === 'mpd';
export const isDashChannel = (
channel: Pick<Channel, 'url'> | null | undefined
): boolean => isDashStreamUrl(channel?.url);
@@ -0,0 +1,194 @@
import { extractDrmFromRaw, isClearKeyLicenseType } from './kodiprop.utils';
const KID_HEX = '9eb4050de44b4802932e27d75083e266';
const KEY_HEX = '166634c675823c235a4a9446fad52e4d';
// base64url encodings of the same 16-byte values as above.
const KID_B64 = 'nrQFDeRLSAKTLifXUIPiZg';
const KEY_B64 = 'FmY0xnWCPCNaSpRG-tUuTQ';
const rawWith = (...kodipropLines: string[]): string =>
[
'#EXTINF:-1 tvg-id="enc" group-title="DASH",Encrypted channel',
...kodipropLines,
'http://example.com/stream.mpd',
].join('\r\n');
describe('kodiprop.utils', () => {
describe('extractDrmFromRaw', () => {
it('parses a single hex kid:key ClearKey pair', () => {
const drm = extractDrmFromRaw(
rawWith(
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${KID_HEX}:${KEY_HEX}`
)
);
expect(drm).toEqual({
licenseType: 'clearkey',
supported: true,
clearKeys: { [KID_HEX]: KEY_HEX },
});
});
it('accepts the org.w3.clearkey license type and dashed UUID kids', () => {
const dashedKid =
'9eb4050d-e44b-4802-932e-27d75083e266'.toUpperCase();
const drm = extractDrmFromRaw(
rawWith(
'#KODIPROP:inputstream.adaptive.license_type=org.w3.clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${dashedKid}:${KEY_HEX}`
)
);
expect(drm?.supported).toBe(true);
expect(drm?.clearKeys).toEqual({ [KID_HEX]: KEY_HEX });
});
it('parses comma-separated multi-key pairs', () => {
const secondKid = 'a'.repeat(32);
const secondKey = 'b'.repeat(32);
const drm = extractDrmFromRaw(
rawWith(
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${KID_HEX}:${KEY_HEX},${secondKid}:${secondKey}`
)
);
expect(drm?.clearKeys).toEqual({
[KID_HEX]: KEY_HEX,
[secondKid]: secondKey,
});
});
it('parses the W3C ClearKey license JSON with base64url values', () => {
const license = JSON.stringify({
keys: [{ kty: 'oct', k: KEY_B64, kid: KID_B64 }],
type: 'temporary',
});
const drm = extractDrmFromRaw(
rawWith(
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${license}`
)
);
expect(drm).toEqual({
licenseType: 'clearkey',
supported: true,
clearKeys: { [KID_HEX]: KEY_HEX },
});
});
it('parses the plain JSON kid→key map form', () => {
const drm = extractDrmFromRaw(
rawWith(
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key={"${KID_HEX}":"${KEY_HEX}"}`
)
);
expect(drm?.clearKeys).toEqual({ [KID_HEX]: KEY_HEX });
});
it('parses the drm_legacy combined property', () => {
const drm = extractDrmFromRaw(
rawWith(
`#KODIPROP:inputstream.adaptive.drm_legacy=org.w3.clearkey|${KID_HEX}:${KEY_HEX}`
)
);
expect(drm).toEqual({
licenseType: 'org.w3.clearkey',
supported: true,
clearKeys: { [KID_HEX]: KEY_HEX },
});
});
it('treats a parseable key without license type as ClearKey', () => {
const drm = extractDrmFromRaw(
rawWith(
`#KODIPROP:inputstream.adaptive.license_key=${KID_HEX}:${KEY_HEX}`
)
);
expect(drm?.supported).toBe(true);
expect(drm?.licenseType).toBe('clearkey');
});
it('marks widevine as unsupported without throwing', () => {
const drm = extractDrmFromRaw(
rawWith(
'#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha',
'#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv'
)
);
expect(drm).toEqual({
licenseType: 'com.widevine.alpha',
supported: false,
});
});
it.each([
['truncated hex pair', `${KID_HEX.slice(0, 10)}:${KEY_HEX}`],
['missing key part', KID_HEX],
['license server URL', 'https://license.example.com/ck'],
['broken JSON', '{"keys":[{'],
['JSON with non-string values', `{"${KID_HEX}": 42}`],
])(
'marks ClearKey with a malformed key value as unsupported (%s)',
(_label, licenseKey) => {
const drm = extractDrmFromRaw(
rawWith(
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${licenseKey}`
)
);
expect(drm).toEqual({
licenseType: 'clearkey',
supported: false,
});
}
);
it('returns undefined for channels without KODIPROP lines', () => {
expect(
extractDrmFromRaw(
'#EXTINF:-1 tvg-id="plain",Plain channel\r\nhttp://example.com/live.m3u8'
)
).toBeUndefined();
});
it('ignores unrelated KODIPROP properties', () => {
expect(
extractDrmFromRaw(
rawWith(
'#KODIPROP:inputstream.adaptive.manifest_type=mpd'
)
)
).toBeUndefined();
});
it('returns undefined for empty or missing raw', () => {
expect(extractDrmFromRaw(undefined)).toBeUndefined();
expect(extractDrmFromRaw('')).toBeUndefined();
});
});
describe('isClearKeyLicenseType', () => {
it.each(['clearkey', 'org.w3.clearkey', ' ClearKey '])(
'accepts %s',
(value) => {
expect(isClearKeyLicenseType(value)).toBe(true);
}
);
it.each(['com.widevine.alpha', 'com.microsoft.playready', ''])(
'rejects %s',
(value) => {
expect(isClearKeyLicenseType(value)).toBe(false);
}
);
});
});
@@ -0,0 +1,279 @@
import { ChannelDrm, ChannelDrmClearKeys } from '@iptvnator/shared/interfaces';
/**
* `#KODIPROP:` DRM extraction.
*
* The playlist parser does not understand `#KODIPROP:` lines, but it appends
* every unknown line between `#EXTINF` and the stream URL to `item.raw`
* (the dominant Kodi/TiviMate layout). This module post-processes that raw
* block into a typed {@link ChannelDrm} value.
*
* Supported properties:
* - `inputstream.adaptive.license_type` + `inputstream.adaptive.license_key`
* - `inputstream.adaptive.drm_legacy` (`<license type>|<license key>`)
*
* ClearKey key formats: single `kid:key` hex pair, comma-separated pairs, the
* W3C ClearKey license JSON (`{"keys":[{"kty":"oct","k":...,"kid":...}]}`)
* and the plain `{kid: key}` JSON map. Anything else (license-server URLs,
* Widevine/PlayReady types, malformed values) yields `supported: false` so the
* player can show a clear DRM diagnostic instead of crashing.
*/
const KODIPROP_PREFIX = '#kodiprop:';
const LICENSE_TYPE_PROP = 'inputstream.adaptive.license_type';
const LICENSE_KEY_PROP = 'inputstream.adaptive.license_key';
const DRM_LEGACY_PROP = 'inputstream.adaptive.drm_legacy';
const CLEARKEY_LICENSE_TYPES = new Set(['clearkey', 'org.w3.clearkey']);
const HEX_128_BIT_PATTERN = /^[0-9a-f]{32}$/;
export function isClearKeyLicenseType(licenseType: string): boolean {
return CLEARKEY_LICENSE_TYPES.has(licenseType.trim().toLowerCase());
}
/**
* Extracts DRM configuration from a playlist item's raw M3U block.
* Returns `undefined` when the block carries no DRM-related `#KODIPROP` lines,
* so channels without KODIPROP metadata stay untouched.
*/
export function extractDrmFromRaw(
raw: string | undefined
): ChannelDrm | undefined {
const props = collectKodipropValues(raw);
if (props.size === 0) {
return undefined;
}
let licenseType = props.get(LICENSE_TYPE_PROP)?.trim() ?? '';
let licenseKey = props.get(LICENSE_KEY_PROP)?.trim() ?? '';
const drmLegacy = props.get(DRM_LEGACY_PROP)?.trim();
if (drmLegacy && (!licenseType || !licenseKey)) {
const [legacyType, ...legacyKeyParts] = drmLegacy.split('|');
licenseType ||= legacyType?.trim() ?? '';
licenseKey ||= legacyKeyParts.join('|').trim();
}
if (!licenseType && !licenseKey) {
return undefined;
}
const normalizedType = licenseType.toLowerCase();
const clearKeys = isClearKeyLicenseType(normalizedType)
? parseClearKeys(licenseKey)
: !normalizedType
? // No explicit type: a parseable kid:key value is ClearKey in practice.
parseClearKeys(licenseKey)
: undefined;
if (clearKeys) {
return {
licenseType: normalizedType || 'clearkey',
supported: true,
clearKeys,
};
}
return {
licenseType: normalizedType,
supported: false,
};
}
function collectKodipropValues(
raw: string | undefined
): Map<string, string> {
const props = new Map<string, string>();
if (!raw) {
return props;
}
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.toLowerCase().startsWith(KODIPROP_PREFIX)) {
continue;
}
const assignment = trimmed.slice(KODIPROP_PREFIX.length);
const separatorIndex = assignment.indexOf('=');
if (separatorIndex < 1) {
continue;
}
const name = assignment.slice(0, separatorIndex).trim().toLowerCase();
const value = assignment.slice(separatorIndex + 1).trim();
if (
name === LICENSE_TYPE_PROP ||
name === LICENSE_KEY_PROP ||
name === DRM_LEGACY_PROP
) {
props.set(name, value);
}
}
return props;
}
function parseClearKeys(value: string): ChannelDrmClearKeys | undefined {
if (!value) {
return undefined;
}
return value.startsWith('{')
? parseClearKeysFromJson(value)
: parseClearKeysFromPairs(value);
}
function parseClearKeysFromPairs(
value: string
): ChannelDrmClearKeys | undefined {
const keys: ChannelDrmClearKeys = {};
for (const pair of value.split(',')) {
const [kid, key, ...rest] = pair.split(':');
if (rest.length > 0) {
return undefined;
}
const kidHex = normalizeKeyComponent(kid);
const keyHex = normalizeKeyComponent(key);
if (!kidHex || !keyHex) {
return undefined;
}
keys[kidHex] = keyHex;
}
return Object.keys(keys).length > 0 ? keys : undefined;
}
function parseClearKeysFromJson(
value: string
): ChannelDrmClearKeys | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return undefined;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
const record = parsed as Record<string, unknown>;
return Array.isArray(record['keys'])
? parseW3cClearKeyLicense(record['keys'])
: parseClearKeyMap(record);
}
/** W3C ClearKey license form: `{"keys":[{"kty":"oct","k":...,"kid":...}]}`. */
function parseW3cClearKeyLicense(
entries: unknown[]
): ChannelDrmClearKeys | undefined {
const keys: ChannelDrmClearKeys = {};
for (const entry of entries) {
if (!entry || typeof entry !== 'object') {
return undefined;
}
const { kid, k } = entry as { kid?: unknown; k?: unknown };
const kidHex = normalizeKeyComponent(
typeof kid === 'string' ? kid : undefined
);
const keyHex = normalizeKeyComponent(
typeof k === 'string' ? k : undefined
);
if (!kidHex || !keyHex) {
return undefined;
}
keys[kidHex] = keyHex;
}
return Object.keys(keys).length > 0 ? keys : undefined;
}
function parseClearKeyMap(
record: Record<string, unknown>
): ChannelDrmClearKeys | undefined {
const keys: ChannelDrmClearKeys = {};
for (const [kid, key] of Object.entries(record)) {
if (typeof key !== 'string') {
return undefined;
}
const kidHex = normalizeKeyComponent(kid);
const keyHex = normalizeKeyComponent(key);
if (!kidHex || !keyHex) {
return undefined;
}
keys[kidHex] = keyHex;
}
return Object.keys(keys).length > 0 ? keys : undefined;
}
/**
* Normalizes a 128-bit key component to 32 lowercase hex chars. Accepts plain
* or dashed (UUID-style) hex and base64url (the W3C license encoding).
*/
function normalizeKeyComponent(value: string | undefined): string | undefined {
const compact = value?.trim().replace(/-/g, '').toLowerCase();
if (!compact) {
return undefined;
}
if (HEX_128_BIT_PATTERN.test(compact)) {
return compact;
}
const fromBase64 = base64UrlToHex(value?.trim() ?? '');
return fromBase64 && HEX_128_BIT_PATTERN.test(fromBase64)
? fromBase64
: undefined;
}
function base64UrlToHex(value: string): string | undefined {
if (!/^[A-Za-z0-9_-]+={0,2}$/.test(value)) {
return undefined;
}
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
try {
const binary = decodeBase64(base64);
let hex = '';
for (let index = 0; index < binary.length; index += 1) {
hex += binary.charCodeAt(index).toString(16).padStart(2, '0');
}
return hex;
} catch {
return undefined;
}
}
/** Works in both the browser (atob) and Node workers (Buffer). */
function decodeBase64(base64: string): string {
if (typeof atob === 'function') {
return atob(base64);
}
const nodeBuffer = (
globalThis as {
Buffer?: {
from(
data: string,
encoding: string
): { toString(encoding: string): string };
};
}
).Buffer;
if (!nodeBuffer) {
throw new Error('No base64 decoder available');
}
return nodeBuffer.from(base64, 'base64').toString('binary');
}
@@ -0,0 +1,127 @@
import { getExtensionFromUrl } from './playlist.utils';
/**
* Normalized media-extension detection for playback URLs. Shared between the
* player engines (engine selection in `@iptvnator/ui/playback`) and M3U
* routing (`isDashChannel`), so both always agree on what a URL is.
*/
export const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([
'avi',
'asf',
'divx',
'flv',
'm2ts',
'm4v',
'mkv',
'mov',
'mpeg',
'mpg',
'rm',
'rmvb',
'ts',
'vob',
'wmv',
]);
const EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS = ['extension', 'ext'];
const DECLARED_MEDIA_FORMAT_QUERY_KEYS = ['format', 'container'];
const DECLARED_MEDIA_EXTENSION_ALIASES = new Map([
['hls', 'm3u8'],
['mpegts', 'ts'],
['mpeg-ts', 'ts'],
]);
const DECLARED_MEDIA_EXTENSIONS = new Set([
...UNSUPPORTED_CONTAINER_EXTENSIONS,
'aac',
'flac',
'm3u',
'm3u8',
'm4s',
'mp3',
'mp4',
'mpd',
'oga',
'ogg',
'ogv',
'webm',
]);
const NON_MEDIA_URL_EXTENSIONS = new Set([
'asp',
'aspx',
'cgi',
'jsp',
'mpv',
'php',
'pl',
]);
export function getPlaybackMediaExtensionFromUrl(url: string): string {
const explicitQueryExtension = getMediaExtensionFromQuery(
url,
EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS
);
if (explicitQueryExtension) {
return explicitQueryExtension;
}
const pathExtension = normalizeExtensionToken(getExtensionFromUrl(url));
if (DECLARED_MEDIA_EXTENSIONS.has(pathExtension)) {
return pathExtension;
}
const formatQueryExtension = getMediaExtensionFromQuery(
url,
DECLARED_MEDIA_FORMAT_QUERY_KEYS
);
if (formatQueryExtension) {
return formatQueryExtension;
}
if (NON_MEDIA_URL_EXTENSIONS.has(pathExtension)) {
return '';
}
return pathExtension;
}
function normalizeExtensionToken(value: string | undefined): string {
return (value?.trim().toLowerCase() ?? '').replace(/^\.+/, '');
}
function getMediaExtensionFromQuery(
url: string,
queryKeys: readonly string[]
): string {
try {
const parsedUrl = new URL(url, 'http://iptvnator.local');
for (const key of queryKeys) {
const declaredExtension = normalizeDeclaredMediaExtension(
parsedUrl.searchParams.get(key) ?? undefined
);
if (declaredExtension) {
return declaredExtension;
}
}
return '';
} catch {
return '';
}
}
function normalizeDeclaredMediaExtension(value: string | undefined): string {
const extension = normalizeExtensionToken(value);
const normalized =
DECLARED_MEDIA_EXTENSION_ALIASES.get(extension) ?? extension;
if (!normalized || NON_MEDIA_URL_EXTENSIONS.has(normalized)) {
return '';
}
return DECLARED_MEDIA_EXTENSIONS.has(normalized) ? normalized : '';
}
@@ -240,6 +240,47 @@ describe('playlist utils', () => {
expect(playlist.epgUrls).toEqual(epgUrls.slice(0, 5));
});
it('attaches ClearKey DRM extracted from raw KODIPROP lines and leaves other channels untouched', () => {
const kid = '9eb4050de44b4802932e27d75083e266';
const key = '166634c675823c235a4a9446fad52e4d';
const baseItem = {
tvg: { id: '', name: '', url: '', logo: '', rec: '' },
group: { title: 'DASH' },
http: { referrer: '', 'user-agent': '' },
};
const playlist = createPlaylistObject('DRM playlist', {
header: { attrs: {}, raw: '#EXTM3U' },
items: [
{
...baseItem,
name: 'Encrypted DASH',
url: 'https://example.com/enc.mpd',
raw: [
'#EXTINF:-1,Encrypted DASH',
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${kid}:${key}`,
'https://example.com/enc.mpd',
].join('\r\n'),
},
{
...baseItem,
name: 'Plain HLS',
url: 'https://example.com/live.m3u8',
raw: '#EXTINF:-1,Plain HLS\r\nhttps://example.com/live.m3u8',
},
],
});
const [encrypted, plain] = playlist.playlist.items;
expect(encrypted.drm).toEqual({
licenseType: 'clearkey',
supported: true,
clearKeys: { [kid]: key },
});
expect(plain.drm).toBeUndefined();
expect('drm' in plain).toBe(false);
});
it('keeps disabled playlist EPG sources out while preserving manually enabled URLs', () => {
expect(
resolvePlaylistEpgSourceState({
@@ -5,6 +5,7 @@ import {
Playlist,
} from '@iptvnator/shared/interfaces';
import { v4 as uuidv4 } from 'uuid';
import { extractDrmFromRaw } from './kodiprop.utils';
/**
* Aggregates favorite channels as objects from all available playlists
@@ -392,10 +393,14 @@ export const createPlaylistObject = (
count: playlist.items.length,
playlist: {
...playlist,
items: playlist.items.map((item: ParsedPlaylistItem) => ({
...item,
id: uuidv4(),
})),
items: playlist.items.map((item: ParsedPlaylistItem) => {
const drm = extractDrmFromRaw(item.raw);
return {
...item,
id: uuidv4(),
...(drm ? { drm } : {}),
};
}),
},
importDate: new Date().toISOString(),
lastUsage: new Date().toISOString(),
@@ -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:
@@ -0,0 +1,85 @@
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import {
createFakeShakaEnvironment,
flushShakaMicrotasks,
} from '../shaka-engine/shaka-player-test-double';
import {
createSession,
hlsInstances,
initArtPlayerSourceSessionModule,
mpegTsInstances,
resetArtPlayerSourceFixtures,
} from './art-player-source-session.spec-fixtures';
describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
beforeAll(async () => {
await initArtPlayerSourceSessionModule();
});
beforeEach(() => {
resetArtPlayerSourceFixtures();
});
it('starts the Shaka engine with channel DRM for the mpd custom type', async () => {
const fakeShaka = createFakeShakaEnvironment();
const drm: ChannelDrm = {
licenseType: 'clearkey',
supported: true,
clearKeys: { abc: 'def' },
};
const { session, player, video } = createSession({
sharedControls: true,
getDrm: () => drm,
loadShaka: fakeShaka.loader,
});
session.attach(player);
session.customType['mpd']?.(
video,
'https://example.test/live.mpd',
player
);
await flushShakaMicrotasks();
expect(fakeShaka.loaderCalls).toBe(1);
expect(fakeShaka.instances).toHaveLength(1);
expect(fakeShaka.instances[0].attachedTo).toBe(video);
expect(fakeShaka.instances[0].loadedUrls).toEqual([
'https://example.test/live.mpd',
]);
expect(fakeShaka.instances[0].configureCalls).toEqual([
{ drm: { clearKeys: { abc: 'def' } } },
]);
expect(hlsInstances).toHaveLength(0);
expect(mpegTsInstances).toHaveLength(0);
});
it('destroys the Shaka engine when the source changes or the session dies', async () => {
const fakeShaka = createFakeShakaEnvironment();
const { session, player, video } = createSession({
sharedControls: false,
loadShaka: fakeShaka.loader,
});
session.attach(player);
session.customType['mpd']?.(
video,
'https://example.test/live.mpd',
player
);
await flushShakaMicrotasks();
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
await flushShakaMicrotasks();
expect(fakeShaka.instances[0].destroyCount).toBe(1);
expect(hlsInstances).toHaveLength(1);
session.destroy();
await flushShakaMicrotasks();
expect(fakeShaka.instances).toHaveLength(1);
});
});
@@ -0,0 +1,191 @@
import type Artplayer from 'artplayer';
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import { WebVideoControlsAdapter } from '../player-controls';
import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types';
import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session';
/**
* Shared engine mocks and session bootstrap for the ArtPlayerSourceSession
* spec files. Importing this module registers the hls.js/mpegts.js module
* mocks, so it must be imported before `initArtPlayerSourceSessionModule()`
* dynamically loads the session under test.
*/
export const hlsInstances: MockHls[] = [];
export const mpegTsInstances: MockMpegTsPlayer[] = [];
export class MockHls {
static Events = {
MANIFEST_PARSED: 'manifestParsed',
ERROR: 'error',
AUDIO_TRACKS_UPDATED: 'audioTracksUpdated',
AUDIO_TRACK_SWITCHING: 'audioTrackSwitching',
AUDIO_TRACK_SWITCHED: 'audioTrackSwitched',
SUBTITLE_TRACKS_UPDATED: 'subtitleTracksUpdated',
SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared',
SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch',
MANIFEST_LOADING: 'manifestLoading',
};
static isSupported = jest.fn(() => true);
readonly handlers = new Map<string, Array<(...args: unknown[]) => void>>();
readonly on = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
const handlers = this.handlers.get(event) ?? [];
handlers.push(handler);
this.handlers.set(event, handlers);
}
);
readonly off = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
const handlers = this.handlers.get(event) ?? [];
this.handlers.set(
event,
handlers.filter((candidate) => candidate !== handler)
);
}
);
readonly attachMedia = jest.fn();
readonly loadSource = jest.fn();
readonly destroy = jest.fn();
audioTracks: Array<{ name?: string; lang?: string }> = [];
audioTrack = 0;
subtitleTracks: Array<{ name?: string; lang?: string }> = [];
subtitleTrack = -1;
subtitleDisplay = false;
constructor() {
hlsInstances.push(this);
}
emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
}
export class MockMpegTsPlayer {
readonly handlers = new Map<string, (...args: unknown[]) => void>();
readonly attachMediaElement = jest.fn();
readonly on = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
this.handlers.set(event, handler);
}
);
readonly off = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
if (this.handlers.get(event) === handler) {
this.handlers.delete(event);
}
}
);
readonly load = jest.fn();
readonly play = jest.fn(() => undefined as Promise<void> | void);
readonly pause = jest.fn();
readonly unload = jest.fn();
readonly detachMediaElement = jest.fn();
readonly destroy = jest.fn();
constructor() {
mpegTsInstances.push(this);
}
}
export const createMpegTsPlayer = jest.fn(() => new MockMpegTsPlayer());
jest.unstable_mockModule('hls.js', () => ({
default: MockHls,
}));
jest.unstable_mockModule('mpegts.js', () => ({
default: {
Events: { ERROR: 'error' },
createPlayer: createMpegTsPlayer,
isSupported: jest.fn(() => true),
},
}));
let sessionConstructor:
| typeof import('./art-player-source-session').ArtPlayerSourceSession
| undefined;
/** Loads the session module after the engine mocks above are registered. */
export async function initArtPlayerSourceSessionModule(): Promise<void> {
({ ArtPlayerSourceSession: sessionConstructor } = await import(
'./art-player-source-session'
));
}
export function resetArtPlayerSourceFixtures(): void {
hlsInstances.length = 0;
mpegTsInstances.length = 0;
createMpegTsPlayer
.mockClear()
.mockImplementation(() => new MockMpegTsPlayer());
MockHls.isSupported.mockReturnValue(true);
}
export function createSession({
sharedControls,
isLive = true,
emitPlaybackIssue = () => undefined,
getDrm,
loadShaka,
}: {
sharedControls: boolean;
isLive?: boolean;
emitPlaybackIssue?: (issue: PlaybackDiagnostic) => void;
getDrm?: () => ChannelDrm | undefined;
loadShaka?: ShakaModuleLoader;
}): {
session: ArtPlayerSourceSessionInstance;
player: Artplayer;
video: HTMLVideoElement;
adapter: WebVideoControlsAdapter;
settingAdd: jest.Mock;
} {
const video = document.createElement('video');
const settingAdd = jest.fn();
const player = {
video,
setting: { add: settingAdd },
} as unknown as Artplayer;
const adapter = new WebVideoControlsAdapter();
const Session = requireSessionConstructor();
return {
session: new Session({
sharedControls,
controlsAdapter: adapter,
isLive: () => isLive,
showCaptions: () => false,
emitPlaybackIssue,
getDrm,
loadShaka,
}),
player,
video,
adapter,
settingAdd,
};
}
function requireSessionConstructor(): typeof import('./art-player-source-session').ArtPlayerSourceSession {
if (!sessionConstructor) {
throw new Error(
'ArtPlayerSourceSession test module is not initialized'
);
}
return sessionConstructor;
}
export function createTimeRanges(ends: number[]): TimeRanges {
return {
length: ends.length,
start: () => 0,
end: (index: number) => ends[index],
};
}
@@ -1,118 +1,23 @@
import type Artplayer from 'artplayer';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import { WebVideoControlsAdapter } from '../player-controls';
import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session';
const hlsInstances: MockHls[] = [];
const mpegTsInstances: MockMpegTsPlayer[] = [];
class MockHls {
static Events = {
MANIFEST_PARSED: 'manifestParsed',
ERROR: 'error',
AUDIO_TRACKS_UPDATED: 'audioTracksUpdated',
AUDIO_TRACK_SWITCHING: 'audioTrackSwitching',
AUDIO_TRACK_SWITCHED: 'audioTrackSwitched',
SUBTITLE_TRACKS_UPDATED: 'subtitleTracksUpdated',
SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared',
SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch',
MANIFEST_LOADING: 'manifestLoading',
};
static isSupported = jest.fn(() => true);
readonly handlers = new Map<string, Array<(...args: unknown[]) => void>>();
readonly on = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
const handlers = this.handlers.get(event) ?? [];
handlers.push(handler);
this.handlers.set(event, handlers);
}
);
readonly off = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
const handlers = this.handlers.get(event) ?? [];
this.handlers.set(
event,
handlers.filter((candidate) => candidate !== handler)
);
}
);
readonly attachMedia = jest.fn();
readonly loadSource = jest.fn();
readonly destroy = jest.fn();
audioTracks: Array<{ name?: string; lang?: string }> = [];
audioTrack = 0;
subtitleTracks: Array<{ name?: string; lang?: string }> = [];
subtitleTrack = -1;
subtitleDisplay = false;
constructor() {
hlsInstances.push(this);
}
emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
}
class MockMpegTsPlayer {
readonly handlers = new Map<string, (...args: unknown[]) => void>();
readonly attachMediaElement = jest.fn();
readonly on = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
this.handlers.set(event, handler);
}
);
readonly off = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
if (this.handlers.get(event) === handler) {
this.handlers.delete(event);
}
}
);
readonly load = jest.fn();
readonly play = jest.fn(() => undefined as Promise<void> | void);
readonly pause = jest.fn();
readonly unload = jest.fn();
readonly detachMediaElement = jest.fn();
readonly destroy = jest.fn();
constructor() {
mpegTsInstances.push(this);
}
}
const createMpegTsPlayer = jest.fn(() => new MockMpegTsPlayer());
jest.unstable_mockModule('hls.js', () => ({
default: MockHls,
}));
jest.unstable_mockModule('mpegts.js', () => ({
default: {
Events: { ERROR: 'error' },
createPlayer: createMpegTsPlayer,
isSupported: jest.fn(() => true),
},
}));
import {
MockHls,
MockMpegTsPlayer,
createMpegTsPlayer,
createSession,
createTimeRanges,
hlsInstances,
initArtPlayerSourceSessionModule,
mpegTsInstances,
resetArtPlayerSourceFixtures,
} from './art-player-source-session.spec-fixtures';
describe('ArtPlayerSourceSession', () => {
let ArtPlayerSourceSession: typeof import('./art-player-source-session').ArtPlayerSourceSession;
beforeAll(async () => {
({ ArtPlayerSourceSession } =
await import('./art-player-source-session'));
sessionConstructor = ArtPlayerSourceSession;
await initArtPlayerSourceSessionModule();
});
beforeEach(() => {
hlsInstances.length = 0;
mpegTsInstances.length = 0;
createMpegTsPlayer.mockClear();
MockHls.isSupported.mockReturnValue(true);
resetArtPlayerSourceFixtures();
});
it('binds HLS tracks to shared controls and tears every listener down exactly', () => {
@@ -336,63 +241,3 @@ describe('ArtPlayerSourceSession', () => {
expect(refresh).toHaveBeenCalled();
});
});
function createSession({
sharedControls,
isLive = true,
emitPlaybackIssue = () => undefined,
}: {
sharedControls: boolean;
isLive?: boolean;
emitPlaybackIssue?: (issue: PlaybackDiagnostic) => void;
}): {
session: ArtPlayerSourceSessionInstance;
player: Artplayer;
video: HTMLVideoElement;
adapter: WebVideoControlsAdapter;
settingAdd: jest.Mock;
} {
const video = document.createElement('video');
const settingAdd = jest.fn();
const player = {
video,
setting: { add: settingAdd },
} as unknown as Artplayer;
const adapter = new WebVideoControlsAdapter();
const Session = requireSessionConstructor();
return {
session: new Session({
sharedControls,
controlsAdapter: adapter,
isLive: () => isLive,
showCaptions: () => false,
emitPlaybackIssue,
}),
player,
video,
adapter,
settingAdd,
};
}
let sessionConstructor:
| typeof import('./art-player-source-session').ArtPlayerSourceSession
| undefined;
function requireSessionConstructor(): typeof import('./art-player-source-session').ArtPlayerSourceSession {
if (!sessionConstructor) {
throw new Error(
'ArtPlayerSourceSession test module is not initialized'
);
}
return sessionConstructor;
}
function createTimeRanges(ends: number[]): TimeRanges {
return {
length: ends.length,
start: () => 0,
end: (index: number) => ends[index],
};
}
@@ -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 {
@@ -136,6 +136,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')
)
);
}
@@ -0,0 +1,81 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import { HtmlVideoPlayerComponent } from './html-video-player.component';
/** DASH-specific behavior of the HTML5 player (legacy controls mode). */
describe('HtmlVideoPlayerComponent DASH', () => {
let component: HtmlVideoPlayerComponent;
let fixture: ComponentFixture<HtmlVideoPlayerComponent>;
const electronApi = {
setUserAgent: jest.fn().mockResolvedValue(true),
};
const TEST_CHANNEL: Channel = {
id: 'dash-1',
url: 'https://stream.example/enc.mpd',
name: 'DASH channel',
group: { title: 'DASH' },
http: { origin: '', referrer: '', 'user-agent': '' },
radio: 'false',
tvg: { id: '', logo: '', name: '', rec: '', url: '' },
};
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()],
providers: [
{
provide: DataService,
useValue: {
sendIpcEvent: jest.fn().mockResolvedValue(undefined),
},
},
],
}).compileComponents();
}));
beforeEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
value: electronApi,
});
fixture = TestBed.createComponent(HtmlVideoPlayerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
jest.restoreAllMocks();
});
it('does not resume the previous source when a channel declares unsupported DRM', () => {
const video = component.videoPlayer.nativeElement;
const loadSpy = jest
.spyOn(video, 'load')
.mockImplementation(() => undefined);
const playSpy = jest.spyOn(video, 'play').mockResolvedValue(undefined);
component.playChannel({
...TEST_CHANNEL,
url: 'https://stream.example/movie.mp4',
});
playSpy.mockClear();
loadSpy.mockClear();
component.playChannel({
...TEST_CHANNEL,
drm: {
licenseType: 'com.widevine.alpha',
supported: false,
},
});
// No source is loaded for unsupported DRM: play() must not run, and
// the element is reset so the old stream cannot keep playing.
expect(playSpy).not.toHaveBeenCalled();
expect(loadSpy).toHaveBeenCalledTimes(1);
});
});
@@ -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 {
@@ -34,7 +30,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,
@@ -90,6 +97,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;
@@ -129,7 +138,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(
@@ -141,29 +158,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
@@ -172,7 +166,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 ?? '');
@@ -191,7 +186,31 @@ 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
);
if (channel.drm && !channel.drm.supported) {
// No source is loaded for unsupported DRM; reset the
// element so the previous stream cannot resume playing
// underneath the diagnostic banner.
this.videoPlayer.nativeElement.load();
} else {
this.handlePlayOperation();
}
} else if (
(extension === 'ts' || !extension) &&
mpegts.isSupported()
) {
debugHtmlPlayer(
'Using mpegts.js for TS stream:',
channel.name,
@@ -209,15 +228,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)
);
}
);
@@ -244,35 +258,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);
@@ -301,6 +298,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
*/
@@ -319,56 +325,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
*/
@@ -380,6 +347,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,13 +230,16 @@ export function classifyUnsupportedHlsManifestCodecs(
});
}
function createDiagnostic(options: {
export function createPlaybackDiagnostic(options: {
readonly code: PlaybackDiagnosticCode;
readonly source: PlaybackDiagnosticSource;
readonly metadata: PlaybackSourceMetadata;
readonly details?: string;
readonly nativeErrorCode?: number;
readonly nativeErrorMessage?: string;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
readonly externalFallbackRecommended?: boolean;
}): PlaybackDiagnostic {
const {
code,
@@ -259,7 +262,9 @@ function createDiagnostic(options: {
details: details || undefined,
nativeErrorCode,
nativeErrorMessage,
externalFallbackRecommended: isExternalFallbackRecommended(code),
externalFallbackRecommended:
options.externalFallbackRecommended ??
isExternalFallbackRecommended(code),
};
}
@@ -1,27 +1,17 @@
import { getExtensionFromUrl } from '@iptvnator/shared/m3u-utils';
import {
UNSUPPORTED_CONTAINER_EXTENSIONS,
getPlaybackMediaExtensionFromUrl,
} from '@iptvnator/shared/m3u-utils';
import type {
PlaybackSourceMetadata,
PlaybackSourceMetadataInput,
} from './playback-diagnostics.model';
const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([
'avi',
'asf',
'divx',
'flv',
'm2ts',
'm4v',
'mkv',
'mov',
'mpeg',
'mpg',
'rm',
'rmvb',
'ts',
'vob',
'wmv',
]);
// Media-extension normalization lives in @iptvnator/shared/m3u-utils so M3U
// routing (isDashChannel) and engine selection always agree; re-exported here
// to keep the playback-lib import surface stable.
export { getPlaybackMediaExtensionFromUrl };
const UNSUPPORTED_CONTAINER_NAMES = new Set([
...UNSUPPORTED_CONTAINER_EXTENSIONS,
@@ -31,42 +21,6 @@ const UNSUPPORTED_CONTAINER_NAMES = new Set([
'x-msvideo',
]);
const EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS = ['extension', 'ext'];
const DECLARED_MEDIA_FORMAT_QUERY_KEYS = ['format', 'container'];
const DECLARED_MEDIA_EXTENSION_ALIASES = new Map([
['hls', 'm3u8'],
['mpegts', 'ts'],
['mpeg-ts', 'ts'],
]);
const DECLARED_MEDIA_EXTENSIONS = new Set([
...UNSUPPORTED_CONTAINER_EXTENSIONS,
'aac',
'flac',
'm3u',
'm3u8',
'm4s',
'mp3',
'mp4',
'mpd',
'oga',
'ogg',
'ogv',
'webm',
]);
const NON_MEDIA_URL_EXTENSIONS = new Set([
'asp',
'aspx',
'cgi',
'jsp',
'mpv',
'php',
'pl',
]);
const BROWSER_LIMITED_CODEC_PATTERNS: ReadonlyArray<{
readonly label: string;
readonly pattern: RegExp;
@@ -95,35 +49,6 @@ export function createPlaybackSourceMetadata(
};
}
export function getPlaybackMediaExtensionFromUrl(url: string): string {
const explicitQueryExtension = getMediaExtensionFromQuery(
url,
EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS
);
if (explicitQueryExtension) {
return explicitQueryExtension;
}
const pathExtension = normalizeExtensionToken(getExtensionFromUrl(url));
if (DECLARED_MEDIA_EXTENSIONS.has(pathExtension)) {
return pathExtension;
}
const formatQueryExtension = getMediaExtensionFromQuery(
url,
DECLARED_MEDIA_FORMAT_QUERY_KEYS
);
if (formatQueryExtension) {
return formatQueryExtension;
}
if (NON_MEDIA_URL_EXTENSIONS.has(pathExtension)) {
return '';
}
return pathExtension;
}
export function getLikelyBrowserUnsupportedCodecLabels(
metadata: Pick<PlaybackSourceMetadata, 'audioCodecs' | 'videoCodecs'>
): string[] {
@@ -188,40 +113,3 @@ function normalizeCodecs(codecs: readonly string[] | undefined): string[] {
function normalizeToken(value: string | undefined): string {
return value?.trim().toLowerCase() ?? '';
}
function normalizeExtensionToken(value: string | undefined): string {
return normalizeToken(value).replace(/^\.+/, '');
}
function getMediaExtensionFromQuery(
url: string,
queryKeys: readonly string[]
): string {
try {
const parsedUrl = new URL(url, 'http://iptvnator.local');
for (const key of queryKeys) {
const declaredExtension = normalizeDeclaredMediaExtension(
parsedUrl.searchParams.get(key) ?? undefined
);
if (declaredExtension) {
return declaredExtension;
}
}
return '';
} catch {
return '';
}
}
function normalizeDeclaredMediaExtension(value: string | undefined): string {
const extension = normalizeExtensionToken(value);
const normalized =
DECLARED_MEDIA_EXTENSION_ALIASES.get(extension) ?? extension;
if (!normalized || NON_MEDIA_URL_EXTENSIONS.has(normalized)) {
return '';
}
return DECLARED_MEDIA_EXTENSIONS.has(normalized) ? normalized : '';
}
@@ -0,0 +1,101 @@
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 without recommending unusable external fallbacks', () => {
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');
// MPV/VLC cannot receive KODIPROP license config, so the diagnostic
// must not offer them as a fallback.
expect(issue.externalFallbackRecommended).toBe(false);
});
});
@@ -0,0 +1,173 @@
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',
// External MPV/VLC cannot receive the KODIPROP license config either,
// so offering them as a fallback would just fail differently.
externalFallbackRecommended: false,
});
}
/** Narrows an unknown rejection to a Shaka-error-like shape, if it is one. */
export 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;
}
export function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : String(error);
}
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,164 @@
import type {
ShakaAudioTrackLike,
ShakaModuleLike,
ShakaModuleLoader,
ShakaPlayerLike,
ShakaTextTrackLike,
} from './shaka-module.types';
/**
* Jest-free Shaka test double shared by the shaka-engine and ArtPlayer specs.
* Mirrors the {@link ShakaPlayerLike} surface, including the real Shaka
* semantic that `destroy()` interrupts an in-flight `load()` with
* `LOAD_INTERRUPTED` (7000).
*/
type Listener = (event: Event) => void;
const LOAD_INTERRUPTED_ERROR = {
severity: 2,
category: 7,
code: 7000,
} as const;
export class FakeShakaPlayer implements ShakaPlayerLike {
readonly configureCalls: Record<string, unknown>[] = [];
readonly selectTextTrackCalls: unknown[] = [];
readonly selectedAudioTracks: ShakaAudioTrackLike[] = [];
readonly listeners = new Map<string, Set<Listener>>();
attachedTo: HTMLMediaElement | null = null;
loadedUrls: string[] = [];
destroyCount = 0;
audioTracks: ShakaAudioTrackLike[] = [];
textTracks: ShakaTextTrackLike[] = [];
/** When true, the next `load()` stays pending until `destroy()`. */
stallNextLoad = false;
loadResult: Promise<unknown> = Promise.resolve();
private pendingLoadReject: ((error: unknown) => void) | null = null;
attach(mediaElement: HTMLMediaElement): Promise<unknown> {
this.attachedTo = mediaElement;
return Promise.resolve();
}
configure(config: Record<string, unknown>): boolean {
this.configureCalls.push(config);
return true;
}
load(assetUri: string): Promise<unknown> {
this.loadedUrls.push(assetUri);
if (this.stallNextLoad) {
this.stallNextLoad = false;
return new Promise((_resolve, reject) => {
this.pendingLoadReject = reject;
});
}
return this.loadResult;
}
destroy(): Promise<unknown> {
this.destroyCount += 1;
this.pendingLoadReject?.(LOAD_INTERRUPTED_ERROR);
this.pendingLoadReject = null;
return Promise.resolve();
}
addEventListener(type: string, listener: Listener): void {
const set = this.listeners.get(type) ?? new Set<Listener>();
set.add(listener);
this.listeners.set(type, set);
}
removeEventListener(type: string, listener: Listener): void {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, detail?: unknown): void {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener({ type, detail } as unknown as Event);
}
}
getAudioTracks(): ShakaAudioTrackLike[] {
return this.audioTracks;
}
selectAudioTrack(track: ShakaAudioTrackLike): void {
this.selectedAudioTracks.push(track);
}
getTextTracks(): ShakaTextTrackLike[] {
return this.textTracks;
}
selectTextTrack(track: ShakaTextTrackLike | null): void {
this.selectTextTrackCalls.push(track);
for (const candidate of this.textTracks) {
candidate.active = candidate === track;
}
}
isLive(): boolean {
return false;
}
}
export interface FakeShakaEnvironment {
/** Every player the fake module has constructed, in creation order. */
readonly instances: FakeShakaPlayer[];
readonly loader: ShakaModuleLoader;
readonly module: ShakaModuleLike;
loaderCalls: number;
installAllCalls: number;
browserSupported: boolean;
}
export function createFakeShakaEnvironment(options?: {
onCreate?: (player: FakeShakaPlayer, index: number) => void;
}): FakeShakaEnvironment {
const instances: FakeShakaPlayer[] = [];
const environment: FakeShakaEnvironment = {
instances,
loaderCalls: 0,
installAllCalls: 0,
browserSupported: true,
module: undefined as unknown as ShakaModuleLike,
loader: undefined as unknown as ShakaModuleLoader,
};
const playerConstructor = function (this: unknown) {
const player = new FakeShakaPlayer();
options?.onCreate?.(player, instances.length);
instances.push(player);
return player;
} as unknown as ShakaModuleLike['Player'];
(
playerConstructor as unknown as { isBrowserSupported: () => boolean }
).isBrowserSupported = () => environment.browserSupported;
const module: ShakaModuleLike = {
Player: playerConstructor,
polyfill: {
installAll: () => {
environment.installAllCalls += 1;
},
},
};
(environment as { module: ShakaModuleLike }).module = module;
(environment as { loader: ShakaModuleLoader }).loader = () => {
environment.loaderCalls += 1;
return Promise.resolve(module);
};
return environment;
}
/** Drains chained microtasks so queued session operations settle. */
export async function flushShakaMicrotasks(): Promise<void> {
for (let index = 0; index < 10; index += 1) {
await Promise.resolve();
}
}
@@ -0,0 +1,42 @@
import type { ShakaPlayerLike } from './shaka-module.types';
/**
* Remembers the text track hidden while the captions preference is off so it
* can be reselected when the preference turns back on — Shaka 5 has no
* separate visibility API (selecting a track shows it, `null` hides text).
*/
export class ShakaTextTrackSuppression {
private suppressedTrackId: number | null = null;
/** Hides the active text track, remembering it for {@link restore}. */
suppress(player: ShakaPlayerLike): void {
const active = player
.getTextTracks()
.find((candidate) => candidate.active);
if (!active) {
return;
}
this.suppressedTrackId = active.id;
player.selectTextTrack(null);
}
/** One-shot reselection of the previously suppressed track. */
restore(player: ShakaPlayerLike): void {
if (this.suppressedTrackId === null) {
return;
}
const track = player
.getTextTracks()
.find((candidate) => candidate.id === this.suppressedTrackId);
this.suppressedTrackId = null;
if (track && !track.active) {
player.selectTextTrack(track);
}
}
reset(): void {
this.suppressedTrackId = null;
}
}
@@ -0,0 +1,294 @@
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
import {
FakeShakaPlayer,
createFakeShakaEnvironment,
flushShakaMicrotasks as flush,
} from './shaka-player-test-double';
import { ShakaVideoSession } from './shaka-video-session';
describe('ShakaVideoSession', () => {
const video = {} as HTMLVideoElement;
let issues: PlaybackDiagnostic[];
const createSession = (
environment: ReturnType<typeof createFakeShakaEnvironment>
) =>
new ShakaVideoSession({
player: InlinePlaybackPlayer.Html5,
emitPlaybackIssue: (issue) => issues.push(issue),
showCaptions: () => false,
loadShaka: environment.loader,
});
beforeEach(() => {
issues = [];
});
it('lazily loads the module once, installs polyfills and starts playback', async () => {
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/a.mpd');
await flush();
session.start(video, 'http://example.com/b.mpd');
await flush();
expect(environment.loaderCalls).toBe(1);
expect(environment.installAllCalls).toBe(1);
const [first, second] = environment.instances;
expect(first.attachedTo).toBe(video);
expect(first.loadedUrls).toEqual(['http://example.com/a.mpd']);
expect(first.destroyCount).toBe(1);
expect(second.loadedUrls).toEqual(['http://example.com/b.mpd']);
expect(issues).toEqual([]);
});
it('configures ClearKey keys before load for supported DRM', async () => {
const environment = createFakeShakaEnvironment();
const drm: ChannelDrm = {
licenseType: 'clearkey',
supported: true,
clearKeys: { abc: 'def' },
};
const session = createSession(environment);
session.start(video, 'http://example.com/enc.mpd', drm);
await flush();
const player = environment.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 environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/clear.mpd');
await flush();
expect(environment.instances[0].configureCalls).toEqual([]);
expect(issues).toEqual([]);
});
it('emits a DRM diagnostic and starts no engine for unsupported license types', async () => {
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/wv.mpd', {
licenseType: 'com.widevine.alpha',
supported: false,
});
await flush();
expect(environment.loaderCalls).toBe(0);
expect(environment.instances).toHaveLength(0);
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issues[0].source).toBe(PlaybackDiagnosticSource.Shaka);
expect(issues[0].details).toContain('com.widevine.alpha');
});
it('classifies critical shaka error events and ignores recoverable ones', async () => {
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/a.mpd');
await flush();
const player = environment.instances[0];
player.dispatch('error', { severity: 1, category: 1, code: 1002 });
expect(issues).toEqual([]);
expect(session.getPlayer()).toBe(player);
player.dispatch('error', { severity: 2, category: 6, code: 6001 });
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
// Without KODIPROP config the DRM hint may still help externally.
expect(issues[0].externalFallbackRecommended).toBe(true);
// Critical errors end playback: the dead engine must be torn down.
expect(player.destroyCount).toBe(1);
expect(session.getPlayer()).toBeNull();
});
it('drops the external-fallback hint for every failure on ClearKey channels', async () => {
const environment = createFakeShakaEnvironment();
const session = createSession(environment);
session.start(video, 'http://example.com/enc.mpd', {
licenseType: 'clearkey',
supported: true,
clearKeys: { abc: 'def' },
});
await flush();
environment.instances[0].dispatch('error', {
severity: 2,
category: 6,
code: 6001,
});
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
// MPV/VLC never receive the KODIPROP license config, so they are not
// offered as a fallback for key failures on ClearKey channels.
expect(issues[0].externalFallbackRecommended).toBe(false);
// Non-DRM failures (media/codec/manifest/…) are equally unsolvable
// externally while the stream itself stays encrypted.
session.start(video, 'http://example.com/enc.mpd', {
licenseType: 'clearkey',
supported: true,
clearKeys: { abc: 'def' },
});
await flush();
environment.instances[1].dispatch('error', {
severity: 2,
category: 3,
code: 3016,
});
expect(issues).toHaveLength(2);
expect(issues[1].code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(issues[1].externalFallbackRecommended).toBe(false);
});
it('emits a diagnostic and tears the engine down when load rejects', async () => {
const loadError = { severity: 2, category: 4, code: 4001 };
const environment = createFakeShakaEnvironment({
onCreate: (player) => {
player.loadResult = Promise.reject(loadError);
},
});
const session = createSession(environment);
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');
// The failed engine must not stay attached or exposed to controls.
expect(environment.instances[0].destroyCount).toBe(1);
expect(session.getPlayer()).toBeNull();
});
it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => {
const environment = createFakeShakaEnvironment({
onCreate: (player, index) => {
if (index === 0) {
player.stallNextLoad = true;
}
},
});
const session = createSession(environment);
session.start(video, 'http://example.com/stalled.mpd');
await flush();
expect(environment.instances[0].loadedUrls).toEqual([
'http://example.com/stalled.mpd',
]);
session.stop();
session.start(video, 'http://example.com/next.mpd');
await flush();
const [stalled, next] = environment.instances;
expect(stalled.destroyCount).toBe(1);
expect(next.loadedUrls).toEqual(['http://example.com/next.mpd']);
expect(session.getPlayer()).toBe(next);
expect(issues).toEqual([]);
});
it('suppresses interrupted loads and stale results after a channel switch', async () => {
let releaseFirstLoad: () => void = () => undefined;
const environment = createFakeShakaEnvironment({
onCreate: (player, index) => {
if (index === 0) {
player.loadResult = new Promise<unknown>((resolve) => {
releaseFirstLoad = () => resolve(undefined);
});
}
},
});
const session = createSession(environment);
session.start(video, 'http://example.com/slow.mpd');
await flush();
session.start(video, 'http://example.com/fast.mpd');
releaseFirstLoad();
await flush();
const [slow, fast] = environment.instances as [
FakeShakaPlayer,
FakeShakaPlayer,
];
expect(slow.destroyCount).toBe(1);
expect(fast.loadedUrls).toEqual(['http://example.com/fast.mpd']);
// The slow player resolved after being superseded; nothing may have
// touched its text tracks afterwards.
expect(slow.selectTextTrackCalls).toEqual([]);
expect(issues).toEqual([]);
});
it('suppresses auto-selected captions on load and restores them on demand', async () => {
const environment = createFakeShakaEnvironment({
onCreate: (player) => {
player.textTracks = [
{
id: 7,
active: true,
language: 'en',
label: 'English',
kind: 'subtitles',
},
];
},
});
const session = createSession(environment);
session.start(video, 'http://example.com/subs.mpd');
await flush();
const player = environment.instances[0];
// showCaptions() is false: the auto-selected track is hidden…
expect(player.selectTextTrackCalls).toEqual([null]);
expect(player.textTracks[0].active).toBe(false);
// …and remembered, so re-enabling the preference restores it.
session.restoreSuppressedTextTrack();
expect(player.textTracks[0].active).toBe(true);
// The restore is one-shot.
session.restoreSuppressedTextTrack();
expect(player.selectTextTrackCalls).toHaveLength(2);
});
it('emits an unsupported-container diagnostic when the browser lacks MSE/EME', async () => {
const environment = createFakeShakaEnvironment();
environment.browserSupported = false;
const session = createSession(environment);
session.start(video, 'http://example.com/a.mpd');
await flush();
expect(environment.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 environment = createFakeShakaEnvironment();
const session = createSession(environment);
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(environment.instances).toHaveLength(1);
expect(environment.instances[0].destroyCount).toBe(1);
});
});
@@ -0,0 +1,388 @@
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 {
asShakaError,
classifyShakaPlaybackIssue,
createUnsupportedDrmDiagnostic,
toErrorMessage,
} from './shaka-error-classifier';
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
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 pendingTeardown: Promise<void> = Promise.resolve();
private readonly textSuppression = new ShakaTextTrackSuppression();
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.beginPlayerTeardown();
this.enqueue(async () => {
await this.pendingTeardown;
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.beginPlayerTeardown();
}
destroy(): void {
if (this.destroyed) {
return;
}
this.destroyed = true;
this.generation += 1;
this.refreshListeners.clear();
this.beginPlayerTeardown();
}
/** 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, drm !== undefined);
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,
drm !== undefined
);
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)) {
this.textSuppression.suppress(player);
}
this.notifyRefresh();
}
/** Hides the active text track, remembering it for a later restore. */
suppressTextTracks(): void {
const player = this.player;
if (player) {
this.textSuppression.suppress(player);
}
}
/** Reselects the text track hidden by {@link suppressTextTracks}. */
restoreSuppressedTextTrack(): void {
const player = this.player;
if (player) {
this.textSuppression.restore(player);
}
}
private handleLoadFailure(
generation: number,
player: ShakaPlayerLike,
url: string,
error: unknown,
drmProvided: boolean
): void {
const shakaError = asShakaError(error);
if (
this.isStale(generation) ||
this.player !== player ||
shakaError?.code === SHAKA_LOAD_INTERRUPTED
) {
return;
}
this.config.emitPlaybackIssue(
this.withoutUnusableDrmFallback(
classifyShakaPlaybackIssue(
shakaError ?? { message: toErrorMessage(error) },
this.createMetadata(url)
),
drmProvided
)
);
// Never leave a non-functional engine attached to the media element
// or exposed to the shared-controls bridge.
this.beginPlayerTeardown();
}
/**
* Channels carrying KODIPROP ClearKey config cannot be handed to MPV/VLC
* at all — external players never receive the license config, so the
* encrypted stream fails there regardless of what broke inline (DRM,
* manifest, codec, network, …). Suppress the fallback hint entirely.
*/
private withoutUnusableDrmFallback(
issue: PlaybackDiagnostic,
drmProvided: boolean
): PlaybackDiagnostic {
if (!drmProvided || !issue.externalFallbackRecommended) {
return issue;
}
return { ...issue, externalFallbackRecommended: false };
}
private bindPlayerListeners(
player: ShakaPlayerLike,
generation: number,
url: string,
drmProvided: boolean
): 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(
this.withoutUnusableDrmFallback(
classifyShakaPlaybackIssue(
detail,
this.createMetadata(url)
),
drmProvided
)
);
// Critical errors end playback; never leave the dead engine
// attached or exposed to the shared-controls bridge.
this.beginPlayerTeardown();
};
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);
}
}
/**
* Detaches and destroys the current engine immediately — never queued
* behind pending operations. `player.destroy()` interrupts an in-flight
* `load()` (it rejects with `LOAD_INTERRUPTED`), so a stalled manifest
* fetch cannot wedge the operation chain. New starts await
* {@link pendingTeardown} before attaching to the media element.
*/
private beginPlayerTeardown(): void {
const player = this.player;
this.player = null;
this.textSuppression.reset();
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();
const teardown = player.destroy().then(
() => undefined,
() => undefined
);
this.pendingTeardown = this.pendingTeardown.then(() => teardown);
}
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();
}
}
}
@@ -128,6 +128,8 @@ function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string {
return 'mpegts.js';
case 'native':
return 'Native media element';
case 'shaka':
return 'Shaka Player';
case 'source':
return 'Stream metadata';
default:
@@ -243,6 +243,7 @@ export class WebPlayerViewComponent {
'',
},
radio: 'false',
drm: playback.drm,
};
}
@@ -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,81 @@
import { InlinePlaybackPlayer } from '../playback-diagnostics/playback-diagnostics.model';
import {
createFakeShakaEnvironment,
flushShakaMicrotasks as flush,
} from '../shaka-engine/shaka-player-test-double';
import { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
import { WebVideoShakaControls } from './web-video-shaka-controls';
describe('WebVideoShakaControls', () => {
const video = {} as HTMLVideoElement;
function createHarness() {
const environment = createFakeShakaEnvironment({
onCreate: (player) => {
player.textTracks = [
{
id: 3,
active: true,
language: 'en',
label: 'English',
kind: 'subtitles',
},
];
},
});
const state = { showCaptions: false };
const session = new ShakaVideoSession({
player: InlinePlaybackPlayer.Html5,
emitPlaybackIssue: () => undefined,
showCaptions: () => state.showCaptions,
loadShaka: environment.loader,
});
const controls = new WebVideoShakaControls({
showCaptions: () => state.showCaptions,
refresh: () => undefined,
});
return { environment, session, controls, state };
}
it('restores the suppressed caption track when the preference turns on', async () => {
const { environment, session, controls, state } = createHarness();
controls.bind(session);
session.start(video, 'http://example.com/subs.mpd');
await flush();
const player = environment.instances[0];
expect(player.textTracks[0].active).toBe(false);
state.showCaptions = true;
controls.refreshInputs();
expect(player.textTracks[0].active).toBe(true);
});
it('keeps an explicit user subtitle-off choice over the preference', async () => {
const { environment, session, controls, state } = createHarness();
controls.bind(session);
session.start(video, 'http://example.com/subs.mpd');
await flush();
controls.setSubtitleTrack(-1);
state.showCaptions = true;
controls.refreshInputs();
const player = environment.instances[0];
expect(player.textTracks[0].active).toBe(false);
});
it('lists subtitle tracks with the active flag as selection state', async () => {
const { environment, session, controls, state } = createHarness();
state.showCaptions = true;
controls.bind(session);
session.start(video, 'http://example.com/subs.mpd');
await flush();
expect(controls.getSubtitleTracks()).toEqual([
{ id: 0, label: 'English', selected: true },
]);
expect(environment.instances[0].textTracks[0].active).toBe(true);
});
});
@@ -0,0 +1,135 @@
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 session = this.session;
if (!session || this.subtitleOverride !== null) {
// A user selection (subtitleOverride) always wins.
return;
}
// Without an explicit user choice, mirror the HLS bridge: keep
// manifest-auto-selected text hidden while the captions preference is
// off, and reselect the suppressed track when it turns back on.
if (this.config.showCaptions()) {
session.restoreSuppressedTextTrack();
} else {
session.suppressTextTracks();
}
}
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();
}