feat(artplayer): add feature-flagged shared controls (#1196)

* feat(artplayer): add feature-flagged shared controls

Co-authored-by: Lars Emig <lars.emig@pickware.de>

* fix(artplayer): align native type and signal inputs

---------

Co-authored-by: Lars Emig <lars.emig@pickware.de>
This commit is contained in:
4grayandLars Emig authored and GitHub committed 2026-07-17 00:12:49 +02:00
1 parent 4e572c60ca
commit 48e3736460
25 files changed
+2650 -965

No files matched your search

+15 -4
View File
@@ -149,8 +149,9 @@ Key files:
recording acknowledgement from being rolled back by a stale reply.
- The built-in HTML5/hls.js player is the second guarded consumer.
`HtmlVideoPlayerComponent` provides a component-scoped
`WebVideoControlsAdapter`; its player-local bridge owns HLS/native tracks,
MPEG-TS VOD duration correction, caption preference, and source cleanup.
`WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared
with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction,
caption preference, and source cleanup.
`HtmlVideoElementSession` owns native video-event lifecycle, persisted
volume, start-time/time/ended propagation, and legacy post-play caption
suppression.
@@ -170,8 +171,18 @@ Key files:
click/double-click/hotkey actions, and spatial navigation;
diagnostic gating and owned-fullscreen exit match HTML5. The flag-off path
keeps the existing Video.js skin and legacy series navigation unchanged.
- ArtPlayer is not wired yet. Its existing skin remains active, and the web
rollout token remains default-off.
- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a
component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns
HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and
a destroyed-session guard for delayed `customType` callbacks, while
`ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared mode uses
authoritative live/VOD metadata, HLS/native tracks and caption preference,
MPEG-TS VOD duration correction, and reapplies app volume directly after
ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled,
and a transparent capture layer gives shared controls exclusive click and
double-click ownership. Diagnostic interaction gating and owned-fullscreen
exit match the other web players. The default-off flag keeps the legacy
ArtPlayer skin, source behavior, and series navigation unchanged.
- Canonical docs: `docs/architecture/player-controls-contract.md` and
`docs/architecture/embedded-mpv-native.md`
+2 -2
View File
@@ -614,11 +614,11 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
**Video Players**:
- Built-in HTML5 player with HLS.js or Video.js
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and a default-off web rollout token. Embedded MPV frame-copy consumes it through `EmbeddedMpvControlsAdapter`; the host selects exactly one UI, so native-view retains its compositor-safe dock. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its local bridge owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. With the flag on, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit the active HTML5 or Video.js shell's own fullscreen so retry/fallback actions remain visible. Default-off paths retain native/Video.js controls and legacy series navigation. ArtPlayer remains unwired with its existing skin. Contract: `docs/architecture/player-controls-contract.md`.
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and a default-off web rollout token. Embedded MPV frame-copy consumes it through `EmbeddedMpvControlsAdapter`; the host selects exactly one UI, so native-view retains its compositor-safe dock. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. With the flag on, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. The rollout flag remains default-off, and all three web players retain their existing controls, source behavior, and legacy series navigation on that path. Contract: `docs/architecture/player-controls-contract.md`.
**VOD/Series Detail Pages (two-state layout)**:
+113 -48
View File
@@ -7,7 +7,8 @@ Embedded MPV rendering and native-view bounds behavior remain documented in
## Current status
The shared-controls foundation from PR #1148 now has three runtime consumers:
The shared-controls foundation from PR #1148 now supports four runtime
consumers and includes:
- the `PlayerController` contract, default state, and capability presets;
- the standalone `app-player-controls` presentation component and its
@@ -19,7 +20,10 @@ The shared-controls foundation from PR #1148 now has three runtime consumers:
- a feature-flagged `HtmlVideoPlayerComponent` integration backed by
`WebVideoControlsAdapter` and a player-local engine bridge;
- a feature-flagged `VjsPlayerComponent` integration backed by a
component-scoped `WebVideoControlsAdapter` and Video.js bridge; and
component-scoped `WebVideoControlsAdapter` and Video.js bridge;
- a feature-flagged `ArtPlayerComponent` integration backed by a
component-scoped `WebVideoControlsAdapter`, neutral web-video source bridge,
and player-local source/video sessions; and
- focused unit/component tests.
When Embedded MPV reports `engine: 'frame-copy'`, the component mounts
@@ -30,10 +34,10 @@ those control systems is active at a time.
When `WEB_PLAYER_SHARED_CONTROLS` is enabled, the built-in HTML5 player mounts
the same presentation component over its real player shell and disables the
native video controls. Its local bridge supplies HLS/native tracks, corrected
MPEG-TS VOD duration, and authoritative live/VOD metadata to the generic web
adapter. When the flag is disabled, the native controls and legacy series
navigation remain unchanged and the adapter is not attached.
native video controls. Its neutral source bridge supplies HLS/native tracks,
corrected MPEG-TS VOD duration, and authoritative live/VOD metadata to the
generic web adapter. When the flag is disabled, the native controls and legacy
series navigation remain unchanged and the adapter is not attached.
Video.js consumes the same token and shared presentation atomically. Its bridge
binds the adapter to the current Video.js Tech `<video>` and rebinds after
@@ -41,9 +45,22 @@ binds the adapter to the current Video.js Tech `<video>` and rebinds after
and manage raw MPEG-TS playback. With the flag disabled, Video.js keeps its
existing skin and legacy series navigation.
ArtPlayer does not consume the shared layer yet. Its existing skin remains
active, and `WEB_PLAYER_SHARED_CONTROLS_ENABLED` remains default-off, so neither
guarded web integration changes normal runtime behavior.
ArtPlayer is the fourth consumer. Its source session owns HLS, MPEG-TS, native
source selection, and delayed `customType` callbacks, while the neutral
web-video source bridge exposes HLS/native tracks, caption preference, and
MPEG-TS VOD duration to the adapter. Its video session owns native media and
ArtPlayer event listeners. Shared mode uses authoritative live/VOD metadata,
reapplies the app volume directly to the media element after ArtPlayer restores
its own stored volume, disables vendor chrome/hotkeys, and places a transparent
event-capture layer over ArtPlayer so shared controls exclusively own surface
clicks and double-clicks. Playback diagnostics gate shared interaction and exit
only the ArtPlayer shell's own fullscreen. Source replacement and teardown
remove exact listeners and engines, and destroyed sessions ignore stale delayed
`customType` callbacks. With the flag disabled, the existing ArtPlayer skin,
source behavior, and legacy series navigation remain unchanged.
`WEB_PLAYER_SHARED_CONTROLS_ENABLED` remains default-off, so none of the three
guarded web integrations changes normal runtime behavior.
This rollout is intentionally engine-selective: frame-copy can use normal DOM
layering, while the native platform view cannot. The integration also includes
@@ -95,7 +112,7 @@ contract does not make a native video surface behave like DOM content.
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Flag-guarded web hosts │ │ EmbeddedMpvPlayerComponent │
│ HTML5 + Video.js │ │ frame-copy: shared controls │
│ HTML5 + Video.js + ArtPlayer │ │ frame-copy: shared controls │
│ flag on: shared controls │ │ native-view: legacy dock │
│ flag off: existing controls │ └──────────────────────────────┘
└──────────────────────────────┘
@@ -103,9 +120,9 @@ contract does not make a native video surface behave like DOM content.
The embedded host selects controls from the reported engine before rendering
them. It never mounts the shared overlay and legacy dock together.
The HTML5 and Video.js hosts likewise select their existing or shared controls
before rendering and never attach the web adapter while the flag-off path is
active.
The HTML5, Video.js, and ArtPlayer hosts likewise select their existing or
shared controls before rendering and never attach the web adapter while the
flag-off path is active.
## The contract
@@ -179,8 +196,9 @@ size follows the fullscreen DOM surface.
`PlayerControlsComponent` is a standalone presentation component. The
frame-copy Embedded MPV host mounts it over its DOM canvas, and the guarded
HTML5 and Video.js hosts mount it over `.html-video-player-shell` and
`.vjs-player-shell`, respectively.
HTML5, Video.js, and ArtPlayer hosts mount it over
`.html-video-player-shell`, `.vjs-player-shell`, and `.art-player-shell`,
respectively.
It owns only transient presentation behavior:
@@ -237,9 +255,9 @@ a modal/backdrop overlay is active, so transport, seek, volume, and fullscreen
actions cannot leak through it. Escape keeps the shared component's generic
popover-dismissal behavior.
The HTML5 and Video.js hosts apply the same ownership rule while a playback
diagnostic is visible: `WebPlayerViewComponent` passes
`interactionEnabled = visiblePlaybackDiagnostic() === null`, and both
The HTML5, Video.js, and ArtPlayer hosts apply the same ownership rule while a
playback diagnostic is visible: `WebPlayerViewComponent` passes
`interactionEnabled = visiblePlaybackDiagnostic() === null`, and all three
components bind that value to `showControls` and
`shortcutsEnabled`. If the active player shell owns DOM fullscreen, its host
exits fullscreen before hiding the controls so the sibling diagnostic banner
@@ -300,24 +318,29 @@ temporarily mislabeled as live. An attached element with no resource maps to
`idle`, paused preload/warm-up remains playable, and only actively playing media
with insufficient data maps to `loading`.
`HtmlVideoPlayerControlsBridge` attaches the adapter to the HTML5 video element
and delegates engine-specific work to focused HLS and native-text-track
collaborators. HLS track IDs remain the list indices accepted by hls.js. Native
caption/subtitle IDs remain stable for the lifetime of a source through a
`WeakMap`, even when the browser removes or reorders tracks. Source replacement
removes track listeners before the old HLS instance is destroyed, resets any
per-source subtitle override, and leaves exactly one engine source bound.
`WebVideoSourceControlsBridge` is the neutral source bridge shared by the HTML5
and ArtPlayer integrations. The HTML5-local bridge/helper filenames remain
compatibility aliases. The bridge attaches the adapter to the host video element
and delegates HLS and native-text-track behavior to focused collaborators. HLS
track IDs remain the list indices accepted by hls.js. Native caption/subtitle
IDs remain stable for the lifetime of a source through a `WeakMap`, even when
the browser removes or reorders tracks. Source replacement removes track
listeners before the old HLS instance is destroyed, resets per-source subtitle
state, and leaves exactly one engine source bound.
Live/VOD classification comes from `WebPlayerViewComponent.resolvedIsLive`:
explicit `ResolvedPortalPlayback.isLive` wins, otherwise content metadata means
VOD and its absence means live. The same computed value configures Video.js,
the HTML5 bridge, and mpegts.js; media duration is never used to infer the
classification.
the HTML5 and ArtPlayer bridges, ArtPlayer itself, and mpegts.js; media duration
is never used to infer the classification. Changing authoritative metadata
restarts an active source when its engine must be recreated with a different
live/VOD mode.
Raw MPEG-TS VOD can expose `video.duration === Infinity`. For that source only,
the bridge uses the first finite positive value from `video.duration`, the last
valid seekable end, or the last valid buffered end. Without a known duration it
keeps the source classified as VOD while seeking remains unavailable.
the neutral bridge used by HTML5 and ArtPlayer uses the first finite positive
value from `video.duration`, the last valid seekable end, or the last valid
buffered end. Without a known duration it keeps the source classified as VOD
while seeking remains unavailable.
`VjsPlayerControlsBridge` attaches the component-scoped adapter to the current
Video.js Tech `<video>`. Video.js can replace that element during `reset()`, so
@@ -348,15 +371,16 @@ the session projects the last finite seekable or buffered end through
`player.duration()`.
`web-video-controls.host.ts` still contains small generic
attachment/projection helpers. The Video.js integration uses its dedicated
bridge directly, and ArtPlayer does not call them yet.
attachment/projection helpers. Video.js uses its dedicated bridge directly.
HTML5 and ArtPlayer share the neutral source bridge and HLS/native-track
collaborators under `web-video-support/`.
The rollout symbols are:
| Symbol | Default | Current effect |
| ------------------------------------ | ------------: | -------------------------------------------------------------------------------------------------------------------------------- |
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Keeps existing web-player skins active in normal runtime builds. |
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view consumed by HTML5 and Video.js to switch atomically between their existing and shared controls. |
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view consumed by HTML5, Video.js, and ArtPlayer to switch atomically between existing and shared UI. |
With the token enabled, Video.js also disables native controls, Video.js
single-click and double-click actions, Video.js hotkeys, and spatial navigation.
@@ -365,6 +389,15 @@ owned exclusively by `app-player-controls`. With the token disabled, existing
Video.js options, plugins, skin, audio-track menu, and series navigation remain
unchanged.
With the token enabled, ArtPlayer disables optional vendor chrome, hotkeys, and
gestures. A transparent capture layer above ArtPlayer's video surface blocks
its always-installed click and double-click handlers while still bubbling
events to the shared controls surface. The shared path reapplies the app volume
directly to `player.video` after ArtPlayer restores `artplayer_settings.volume`,
so vendor storage cannot override the app-wide preference. With the token
disabled, the existing ArtPlayer options, HLS audio settings, skin, source
semantics, stored volume behavior, and series navigation remain unchanged.
## Embedded MPV rendering constraints
The shared contract does not replace either Embedded MPV renderer. The host
@@ -414,12 +447,10 @@ renderer, bounds, and platform details.
The remaining design seams are:
1. **ArtPlayer** — add an engine-specific adapter/bridge, consume the rollout
token, and remove its skin only when shared controls are active.
2. **Native-view UI** — retain the compositor-safe dock unless the native
1. **Native-view UI** — retain the compositor-safe dock unless the native
engine's compositing architecture changes independently. A native-view
migration is not part of the frame-copy rollout.
3. **Background playback** — introduce a persistent player/session host above
2. **Background playback** — introduce a persistent player/session host above
route-scoped views. The contract is lifecycle-agnostic; this integration
does not add that host or change current teardown behavior.
@@ -468,6 +499,19 @@ libs/ui/playback/src/lib/embedded-mpv-player/
The adapter and recording helpers are component-scoped through
`EmbeddedMpvPlayerComponent`.
The neutral web-video source support shared by HTML5 and ArtPlayer lives in:
```text
libs/ui/playback/src/lib/web-video-support/
├── web-video-hls-controls.ts
├── web-video-native-text-tracks.ts
└── web-video-source-controls.bridge.ts
```
The bridge owns source-local HLS/native track projection, caption preference
and explicit subtitle-off state, MPEG-TS VOD duration correction, adapter
refresh, and exact track-list listener cleanup.
The guarded HTML5 integration lives in:
```text
@@ -481,11 +525,11 @@ libs/ui/playback/src/lib/html-video-player/
```
`HtmlVideoPlayerComponent` provides a component-scoped
`WebVideoControlsAdapter`. The bridge and its collaborators are player-local
because HLS/native track identity, caption preference, and cleanup are tied to
one active source. `HtmlVideoElementSession` separately owns native video-event
attachment, persisted volume, start-time/time/ended propagation, and the
flag-off post-play caption behavior.
`WebVideoControlsAdapter`. Its bridge/helper filenames re-export the neutral
web-video support so existing imports and focused specs remain stable.
`HtmlVideoElementSession` separately owns native video-event attachment,
persisted volume, start-time/time/ended propagation, and the flag-off post-play
caption behavior.
The guarded Video.js integration lives in:
@@ -510,9 +554,30 @@ identity, caption preference/override projection, and exact listener cleanup.
preservation, while `VjsVideoElementSession` owns native Tech-element
playback/ended events.
Focused specs cover the Video.js flag-off compatibility path, shared-controls
rendering and interaction gating, Tech replacement, track-list lifecycle and
stable IDs, subtitle preference/explicit-off behavior, pause/coalesced-reset
ordering and volume preservation for MPEG-TS, duration projection, and
collaborator teardown. ArtPlayer skin removal and persistent/background player
ownership have not landed.
The guarded ArtPlayer integration lives in:
```text
libs/ui/playback/src/lib/art-player/
├── art-player-setup.ts
├── art-player-source-session.ts
├── art-player-video-session.ts
├── art-player.component.ts
├── art-player.component.html
└── art-player.component.scss
```
`ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`.
`ArtPlayerSourceSession` owns HLS/MPEG-TS/native engines, the neutral source
bridge, exact engine/listener cleanup, and a destroyed-session guard for
ArtPlayer's delayed `customType` dispatch. `ArtPlayerVideoSession` owns native
media errors, readiness, volume persistence, ended/time updates, and exact
event cleanup. The setup helper preserves the legacy option set when the flag
is off and disables vendor interaction owners when it is on; the component's
transparent capture layer blocks ArtPlayer's core surface handlers.
Focused specs cover each web engine's flag-off compatibility path,
shared-controls rendering and diagnostic interaction gating, source/element
replacement, track-list lifecycle and stable IDs, caption preference and
explicit-off behavior, MPEG-TS live/VOD handling and duration projection,
volume preservation/authority, stale ArtPlayer `customType` callbacks, and
collaborator teardown. Persistent/background player ownership has not landed.
@@ -0,0 +1,140 @@
import {
buildArtPlayerChrome,
exitOwnedArtPlayerFullscreen,
getArtPlayerVideoType,
resolveArtPlayerIsLive,
} from './art-player-setup';
describe('ArtPlayer setup', () => {
it('preserves the complete legacy chrome when shared controls are disabled', () => {
expect(buildArtPlayerChrome(false)).toEqual({
pip: true,
autoPlayback: true,
autoSize: true,
autoMini: true,
screenshot: true,
setting: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
fullscreenWeb: true,
airplay: true,
});
});
it('removes every duplicate ArtPlayer interaction surface in shared mode', () => {
expect(buildArtPlayerChrome(true)).toEqual({
controls: [],
pip: false,
autoPlayback: false,
autoSize: false,
autoMini: false,
screenshot: false,
setting: false,
playbackRate: false,
aspectRatio: false,
fullscreen: false,
fullscreenWeb: false,
airplay: false,
hotkey: false,
fastForward: false,
autoOrientation: false,
lock: false,
gesture: false,
miniProgressBar: false,
subtitleOffset: false,
});
});
it('uses authoritative live metadata only in shared mode', () => {
expect(
resolveArtPlayerIsLive(true, false, 'https://example.test/movie.ts')
).toBe(false);
expect(
resolveArtPlayerIsLive(true, true, 'https://example.test/movie.mp4')
).toBe(true);
expect(
resolveArtPlayerIsLive(
false,
false,
'https://example.test/movie.ts'
)
).toBe(true);
expect(
resolveArtPlayerIsLive(
false,
true,
'https://example.test/movie.mp4'
)
).toBe(false);
});
it('keeps extensionless IPTV URLs on MPEG-TS playback', () => {
expect(
getArtPlayerVideoType('https://example.test/live.php?id=7')
).toBe('ts');
expect(
getArtPlayerVideoType(
'https://example.test/play?extension=m3u8&token=signed'
)
).toBe('m3u8');
expect(getArtPlayerVideoType('https://example.test/movie.mkv')).toBe(
'video/matroska'
);
});
it('exits fullscreen only when the shared ArtPlayer shell owns it', () => {
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
document,
'fullscreenElement'
);
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
);
const surface = document.createElement('div');
let fullscreenElement: Element | null = document.createElement('div');
const exitFullscreen = jest.fn().mockResolvedValue(undefined);
const reportError = jest.fn();
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
try {
exitOwnedArtPlayerFullscreen(true, surface, reportError);
expect(exitFullscreen).not.toHaveBeenCalled();
fullscreenElement = surface;
exitOwnedArtPlayerFullscreen(false, surface, reportError);
expect(exitFullscreen).not.toHaveBeenCalled();
exitOwnedArtPlayerFullscreen(true, surface, reportError);
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(reportError).not.toHaveBeenCalled();
} finally {
restoreDocumentProperty(
'fullscreenElement',
fullscreenElementDescriptor
);
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
}
});
});
function restoreDocumentProperty(
property: 'exitFullscreen' | 'fullscreenElement',
descriptor: PropertyDescriptor | undefined
): void {
if (descriptor) {
Object.defineProperty(document, property, descriptor);
return;
}
delete (document as unknown as Record<string, unknown>)[property];
}
@@ -0,0 +1,102 @@
import { getPlaybackMediaExtensionFromUrl } from '../playback-diagnostics/playback-diagnostics.util';
/**
* ArtPlayer option overrides for the legacy and shared-control surfaces.
*
* The flag-off branch deliberately mirrors the existing runtime options. The
* flag-on branch disables every optional ArtPlayer interaction owner; a
* transparent capture surface blocks the core click/double-click handlers that
* ArtPlayer installs independently of these options.
*/
export function buildArtPlayerChrome(
sharedControls: boolean
): Record<string, unknown> {
if (!sharedControls) {
return {
pip: true,
autoPlayback: true,
autoSize: true,
autoMini: true,
screenshot: true,
setting: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
fullscreenWeb: true,
airplay: true,
};
}
return {
controls: [],
pip: false,
autoPlayback: false,
autoSize: false,
autoMini: false,
screenshot: false,
setting: false,
playbackRate: false,
aspectRatio: false,
fullscreen: false,
fullscreenWeb: false,
airplay: false,
hotkey: false,
fastForward: false,
autoOrientation: false,
lock: false,
gesture: false,
miniProgressBar: false,
subtitleOffset: false,
};
}
export function resolveArtPlayerIsLive(
sharedControls: boolean,
authoritativeIsLive: boolean,
url: string
): boolean {
if (sharedControls) {
return authoritativeIsLive;
}
const extension = getPlaybackMediaExtensionFromUrl(url);
return extension === 'm3u8' || extension === 'ts' || !extension;
}
export function getArtPlayerVideoType(url: string): string {
const extension = getPlaybackMediaExtensionFromUrl(url);
switch (extension) {
case 'mkv':
return 'video/matroska';
case 'm3u8':
return 'm3u8';
case 'mp4':
return 'mp4';
case 'ts':
return 'ts';
default:
// Extensionless IPTV proxy/script URLs are predominantly raw
// MPEG-TS and retain the established ArtPlayer fallback.
return extension ? 'auto' : 'ts';
}
}
export function exitOwnedArtPlayerFullscreen(
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,398 @@
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),
},
}));
describe('ArtPlayerSourceSession', () => {
let ArtPlayerSourceSession: typeof import('./art-player-source-session').ArtPlayerSourceSession;
beforeAll(async () => {
({ ArtPlayerSourceSession } =
await import('./art-player-source-session'));
sessionConstructor = ArtPlayerSourceSession;
});
beforeEach(() => {
hlsInstances.length = 0;
mpegTsInstances.length = 0;
createMpegTsPlayer.mockClear();
MockHls.isSupported.mockReturnValue(true);
});
it('binds HLS tracks to shared controls and tears every listener down exactly', () => {
const { session, player, video, adapter } = createSession({
sharedControls: true,
});
const attach = jest.spyOn(adapter, 'attach');
const detach = jest.spyOn(adapter, 'detach');
session.attach(player);
detach.mockClear();
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
const hls = hlsInstances[0];
hls.audioTracks = [{ name: 'Main' }, { name: 'Alternate' }];
hls.emit(MockHls.Events.AUDIO_TRACKS_UPDATED);
expect(attach).toHaveBeenCalledWith(
video,
expect.objectContaining({
getAudioTracks: expect.any(Function),
getSubtitleTracks: expect.any(Function),
})
);
expect(adapter.capabilities().audioTracks).toBe(true);
expect(hls.attachMedia).toHaveBeenCalledWith(video);
expect(hls.loadSource).toHaveBeenCalledWith(
'https://example.test/live.m3u8'
);
session.destroy();
expect(detach).toHaveBeenCalledTimes(1);
expect(hls.off).toHaveBeenCalledWith(
MockHls.Events.MANIFEST_PARSED,
expect.any(Function)
);
expect(hls.off).toHaveBeenCalledWith(
MockHls.Events.ERROR,
expect.any(Function)
);
expect(hls.off).toHaveBeenCalledWith(
MockHls.Events.AUDIO_TRACKS_UPDATED,
expect.any(Function)
);
expect(hls.destroy).toHaveBeenCalledTimes(1);
});
it('keeps the legacy HLS audio menu and does not attach shared controls', () => {
const { session, player, video, adapter, settingAdd } = createSession({
sharedControls: false,
});
const attach = jest.spyOn(adapter, 'attach');
session.attach(player);
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
const hls = hlsInstances[0];
hls.audioTracks = [{ name: 'Main' }, { name: 'Alternate' }];
hls.emit(MockHls.Events.AUDIO_TRACKS_UPDATED);
expect(settingAdd).toHaveBeenCalledTimes(1);
expect(attach).not.toHaveBeenCalled();
});
it('uses authoritative VOD metadata for shared MPEG-TS and catches autoplay rejection', async () => {
const { session, player, video, adapter } = createSession({
sharedControls: true,
isLive: false,
});
const rejectedPlay = Promise.reject(new Error('autoplay blocked'));
const engine = new MockMpegTsPlayer();
engine.play.mockReturnValue(rejectedPlay);
createMpegTsPlayer.mockReturnValueOnce(engine);
Object.defineProperty(video, 'duration', {
configurable: true,
value: Number.POSITIVE_INFINITY,
});
Object.defineProperty(video, 'seekable', {
configurable: true,
value: createTimeRanges([135]),
});
session.attach(player);
session.customType['ts']?.(
video,
'https://example.test/movie.ts',
player
);
await rejectedPlay.catch(() => undefined);
expect(createMpegTsPlayer).toHaveBeenCalledWith({
type: 'mpegts',
isLive: false,
url: 'https://example.test/movie.ts',
});
expect(adapter.state()).toEqual(
expect.objectContaining({
isLive: false,
durationSeconds: 135,
})
);
expect(session.resolveDuration(Number.POSITIVE_INFINITY)).toBe(135);
expect(engine.attachMediaElement).toHaveBeenCalledWith(video);
expect(engine.load).toHaveBeenCalledTimes(1);
expect(engine.play).toHaveBeenCalledTimes(1);
});
it('preserves legacy live MPEG-TS semantics when the rollout flag is off', () => {
const { session, player, video } = createSession({
sharedControls: false,
isLive: false,
});
session.attach(player);
session.customType['ts']?.(
video,
'https://example.test/movie.ts',
player
);
expect(createMpegTsPlayer).toHaveBeenCalledWith({
type: 'mpegts',
isLive: true,
url: 'https://example.test/movie.ts',
});
});
it('reports HLS and MPEG-TS diagnostics from the session-local source', () => {
const emitted: PlaybackDiagnostic[] = [];
const { session, player, video } = createSession({
sharedControls: true,
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
const staleHlsError = hlsInstances[0].handlers.get(
MockHls.Events.ERROR
)?.[0];
hlsInstances[0].emit(MockHls.Events.ERROR, null, {
type: 'mediaError',
details: 'bufferAddCodecError',
fatal: true,
error: new Error('unsupported codec'),
});
session.customType['ts']?.(
video,
'https://example.test/live.ts',
player
);
staleHlsError?.(null, {
type: 'networkError',
details: 'stale callback',
fatal: true,
});
mpegTsInstances.at(-1)?.handlers.get('error')?.(
'mediaError',
'unsupported codec',
{}
);
expect(emitted).toEqual([
expect.objectContaining({
source: 'hls',
sourceUrl: 'https://example.test/live.m3u8',
player: 'artplayer',
}),
expect.objectContaining({
source: 'mpegts',
sourceUrl: 'https://example.test/live.ts',
player: 'artplayer',
}),
]);
});
it('ignores a delayed customType callback after its player was destroyed', () => {
const { session, player, video } = createSession({
sharedControls: true,
});
session.attach(player);
session.destroy();
session.customType['m3u8']?.(
video,
'https://example.test/stale.m3u8',
player
);
session.customType['ts']?.(
video,
'https://example.test/stale.ts',
player
);
expect(hlsInstances).toHaveLength(0);
expect(createMpegTsPlayer).not.toHaveBeenCalled();
});
it('refreshes caption inputs only through the active shared bridge', () => {
const { session, player, adapter } = createSession({
sharedControls: true,
});
const refresh = jest.spyOn(adapter, 'refresh');
session.attach(player);
refresh.mockClear();
session.refreshInputs();
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],
};
}
@@ -0,0 +1,296 @@
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 {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyHlsPlaybackIssue,
classifyMpegTsPlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
import type { WebVideoControlsAdapter } from '../player-controls';
import {
type WebVideoControlsSource,
WebVideoSourceControlsBridge,
} from '../web-video-support/web-video-source-controls.bridge';
import { addHlsAudioTrackSettings } from './art-player-audio-tracks';
export interface ArtPlayerSourceSessionConfig {
sharedControls: boolean;
controlsAdapter: WebVideoControlsAdapter;
isLive: () => boolean;
showCaptions: () => boolean;
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void;
}
/**
* Owns ArtPlayer's custom source engines and the shared-controls track bridge.
*
* A session belongs to one ArtPlayer instance. Its customType callbacks use the
* callback-local `art` argument and become no-ops after destroy, so ArtPlayer's
* delayed URL dispatch cannot mutate a newer component player.
*/
export class ArtPlayerSourceSession {
readonly customType: NonNullable<Option['customType']> = {
m3u8: (video, url, art) => this.startHls(video, url, art),
ts: (video, url) => this.startMpegTs(video, url),
'video/matroska': (video, url) => this.startNative(video, url),
};
private player: Artplayer | null = null;
private controlsBridge: WebVideoSourceControlsBridge | null = null;
private pendingControlsSource: WebVideoControlsSource = { kind: 'native' };
private hls: Hls | null = null;
private hlsManifestListener:
| ((event: unknown, data: ManifestParsedData) => void)
| null = null;
private hlsErrorListener:
| ((event: unknown, data: ErrorData) => void)
| null = null;
private mpegTsPlayer: mpegts.Player | null = null;
private mpegTsErrorListener:
| ((type: string, details: string, info: unknown) => void)
| null = null;
private destroyed = false;
constructor(private readonly config: ArtPlayerSourceSessionConfig) {}
attach(player: Artplayer): void {
if (this.destroyed || this.player) {
return;
}
this.player = player;
if (!this.config.sharedControls) {
return;
}
const bridge = new WebVideoSourceControlsBridge({
video: player.video,
adapter: this.config.controlsAdapter,
isLive: this.config.isLive,
showCaptions: this.config.showCaptions,
});
this.controlsBridge = bridge;
bridge.attach();
bridge.setSource(this.pendingControlsSource);
}
refreshInputs(): void {
this.controlsBridge?.refreshInputs();
}
resolveDuration(fallbackDuration: number): number {
const correctedDuration = this.controlsBridge?.readDuration() ?? NaN;
return Number.isNaN(correctedDuration)
? fallbackDuration
: correctedDuration;
}
destroy(): void {
if (this.destroyed) {
return;
}
this.destroyed = true;
this.controlsBridge?.destroy();
this.controlsBridge = null;
this.destroyHls();
this.destroyMpegTs();
this.player = null;
}
private startHls(
video: HTMLVideoElement,
url: string,
art: Artplayer
): void {
if (this.destroyed) {
return;
}
this.prepareForSourceChange();
if (Hls.isSupported()) {
const hls = new Hls();
this.hls = hls;
this.hlsManifestListener = (_event, data) => {
if (this.destroyed || this.hls !== hls) {
return;
}
this.handleHlsManifestParsed(url, data);
};
this.hlsErrorListener = (_event, data) => {
if (this.destroyed || this.hls !== hls) {
return;
}
this.handleHlsError(url, data);
};
hls.on(Hls.Events.MANIFEST_PARSED, this.hlsManifestListener);
hls.on(Hls.Events.ERROR, this.hlsErrorListener);
hls.attachMedia(video);
this.bindControlsSource({ kind: 'hls', hls });
hls.loadSource(url);
if (!this.config.sharedControls) {
addHlsAudioTrackSettings(art, hls);
}
return;
}
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
this.bindControlsSource({ kind: 'native' });
}
}
private startMpegTs(video: HTMLVideoElement, url: string): void {
if (this.destroyed) {
return;
}
this.prepareForSourceChange();
if (!mpegts.isSupported()) {
return;
}
const engine = mpegts.createPlayer({
type: 'mpegts',
isLive: this.config.sharedControls ? this.config.isLive() : true,
url,
});
this.mpegTsPlayer = engine;
this.mpegTsErrorListener = (type, details, info) => {
if (this.destroyed || this.mpegTsPlayer !== engine) {
return;
}
this.config.emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
{ type, details, info },
this.createSourceMetadata(url, 'video/mp2t')
)
);
};
engine.attachMediaElement(video);
this.bindControlsSource({ kind: 'mpegts' });
engine.on(mpegts.Events.ERROR, this.mpegTsErrorListener);
engine.load();
const playResult = engine.play();
if (playResult) {
void playResult.catch(() => undefined);
}
}
private startNative(video: HTMLVideoElement, url: string): void {
if (this.destroyed) {
return;
}
this.prepareForSourceChange();
video.src = url;
this.bindControlsSource({ kind: 'native' });
}
private prepareForSourceChange(): void {
this.controlsBridge?.clearSource();
this.destroyHls();
this.destroyMpegTs();
}
private bindControlsSource(source: WebVideoControlsSource): void {
this.pendingControlsSource = source;
this.controlsBridge?.setSource(source);
}
private destroyHls(): void {
const hls = this.hls;
if (!hls) {
this.hlsManifestListener = null;
this.hlsErrorListener = null;
return;
}
if (this.hlsManifestListener) {
hls.off(Hls.Events.MANIFEST_PARSED, this.hlsManifestListener);
}
if (this.hlsErrorListener) {
hls.off(Hls.Events.ERROR, this.hlsErrorListener);
}
this.hlsManifestListener = null;
this.hlsErrorListener = null;
hls.destroy();
this.hls = null;
}
private destroyMpegTs(): void {
const engine = this.mpegTsPlayer;
if (!engine) {
this.mpegTsErrorListener = null;
return;
}
if (this.mpegTsErrorListener) {
engine.off(mpegts.Events.ERROR, this.mpegTsErrorListener);
}
this.mpegTsErrorListener = null;
engine.pause();
engine.unload();
engine.detachMediaElement();
engine.destroy();
this.mpegTsPlayer = null;
}
private handleHlsManifestParsed(
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))
);
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
if (issue) {
this.config.emitPlaybackIssue(issue);
}
}
private handleHlsError(url: string, data: ErrorData): void {
if (!data.fatal) {
return;
}
this.config.emitPlaybackIssue(
classifyHlsPlaybackIssue(
{
type: data.type,
details: data.details,
fatal: data.fatal,
message: data.error?.message,
error: data.error,
},
this.createSourceMetadata(url, 'application/x-mpegURL')
)
);
}
private createSourceMetadata(
url: string,
mimeType?: string,
audioCodecs: readonly string[] = [],
videoCodecs: readonly string[] = []
) {
return createPlaybackSourceMetadata({
url,
mimeType,
player: InlinePlaybackPlayer.ArtPlayer,
audioCodecs,
videoCodecs,
});
}
}
@@ -0,0 +1,180 @@
import type Artplayer from 'artplayer';
import { ArtPlayerVideoSession } from './art-player-video-session';
describe('ArtPlayerVideoSession', () => {
beforeEach(() => {
localStorage.clear();
});
it('owns ready, time, ended, and native diagnostic propagation', () => {
const player = createPlayer();
const issues: unknown[] = [];
const times: unknown[] = [];
const ended = jest.fn();
const session = new ArtPlayerVideoSession({
player,
sourceUrl: 'https://example.test/movie.mkv',
getStartTime: () => 42,
getDuration: () => player.duration,
persistSharedVolume: false,
emitPlaybackIssue: (issue) => issues.push(issue),
emitTimeUpdate: (value) => times.push(value),
emitPlaybackEnded: ended,
});
session.attach();
player.emit('ready');
expect(player.seek).toBe(42);
player.currentTime = 45;
player.duration = 120;
player.emit('video:timeupdate');
expect(times).toEqual([{ currentTime: 45, duration: 120 }]);
Object.defineProperty(player.video, 'error', {
configurable: true,
value: { code: 4, message: 'unsupported source' },
});
player.video.dispatchEvent(new Event('error'));
expect(issues.at(-1)).toEqual(
expect.objectContaining({
code: 'unsupported-container',
sourceUrl: 'https://example.test/movie.mkv',
player: 'artplayer',
})
);
player.video.dispatchEvent(new Event('loadeddata'));
player.video.dispatchEvent(new Event('playing'));
player.video.dispatchEvent(new Event('ended'));
expect(issues.slice(-2)).toEqual([null, null]);
expect(ended).toHaveBeenCalledTimes(1);
});
it('persists shared-control volume without changing legacy storage behavior', () => {
const sharedPlayer = createPlayer();
const sharedSession = createSession(sharedPlayer, true);
sharedSession.attach();
sharedPlayer.video.volume = 0.35;
sharedPlayer.video.dispatchEvent(new Event('volumechange'));
expect(localStorage.getItem('volume')).toBe('0.35');
localStorage.removeItem('volume');
const legacyPlayer = createPlayer();
const legacySession = createSession(legacyPlayer, false);
legacySession.attach();
legacyPlayer.video.volume = 0.7;
legacyPlayer.video.dispatchEvent(new Event('volumechange'));
expect(localStorage.getItem('volume')).toBeNull();
});
it('uses the source-resolved duration for time updates', () => {
const player = createPlayer();
const times: Array<{ currentTime: number; duration: number }> = [];
player.currentTime = 45;
player.duration = Number.POSITIVE_INFINITY;
const session = new ArtPlayerVideoSession({
player,
sourceUrl: 'https://example.test/movie.ts',
getStartTime: () => 0,
getDuration: () => 135,
persistSharedVolume: true,
emitPlaybackIssue: () => undefined,
emitTimeUpdate: (value) => times.push(value),
emitPlaybackEnded: () => undefined,
});
session.attach();
player.emit('video:timeupdate');
expect(times).toEqual([{ currentTime: 45, duration: 135 }]);
});
it('removes native and ArtPlayer listeners exactly on destroy', () => {
const player = createPlayer();
const ended = jest.fn();
const session = new ArtPlayerVideoSession({
player,
sourceUrl: 'https://example.test/movie.mp4',
getStartTime: () => 0,
getDuration: () => player.duration,
persistSharedVolume: true,
emitPlaybackIssue: () => undefined,
emitTimeUpdate: () => undefined,
emitPlaybackEnded: ended,
});
const removeEventListener = jest.spyOn(
player.video,
'removeEventListener'
);
session.attach();
session.destroy();
session.destroy();
player.video.dispatchEvent(new Event('ended'));
player.emit('video:timeupdate');
expect(removeEventListener).toHaveBeenCalledWith(
'ended',
expect.any(Function)
);
expect(player.off).toHaveBeenCalledWith('ready', expect.any(Function));
expect(player.off).toHaveBeenCalledWith(
'video:timeupdate',
expect.any(Function)
);
expect(ended).not.toHaveBeenCalled();
});
});
function createSession(
player: MockArtplayer,
persistSharedVolume: boolean
): ArtPlayerVideoSession {
return new ArtPlayerVideoSession({
player,
sourceUrl: 'https://example.test/movie.mp4',
getStartTime: () => 0,
getDuration: () => player.duration,
persistSharedVolume,
emitPlaybackIssue: () => undefined,
emitTimeUpdate: () => undefined,
emitPlaybackEnded: () => undefined,
});
}
class MockArtplayer {
readonly video = document.createElement('video');
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);
return this;
}
);
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)
);
return this;
}
);
currentTime = 0;
duration = 0;
seek = 0;
emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
}
function createPlayer(): MockArtplayer & Artplayer {
return new MockArtplayer() as MockArtplayer & Artplayer;
}
@@ -0,0 +1,109 @@
import type Artplayer from 'artplayer';
import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
export interface ArtPlayerVideoSessionConfig {
player: Artplayer;
sourceUrl: string;
getStartTime: () => number;
getDuration: () => number;
persistSharedVolume: boolean;
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitPlaybackEnded: () => void;
}
/** Owns native video and ArtPlayer event listeners for one player instance. */
export class ArtPlayerVideoSession {
private attached = false;
private destroyed = false;
private readonly handleNativePlaybackError = (): void => {
const { player, sourceUrl } = this.config;
this.config.emitPlaybackIssue(
classifyNativePlaybackIssue(
player.video.error,
createPlaybackSourceMetadata({
url: sourceUrl || player.video.currentSrc,
player: InlinePlaybackPlayer.ArtPlayer,
})
)
);
};
private readonly clearPlaybackIssue = (): void => {
this.config.emitPlaybackIssue(null);
};
private readonly handleVolumeChange = (): void => {
if (!this.config.persistSharedVolume) {
return;
}
localStorage.setItem(
'volume',
this.config.player.video.volume.toString()
);
};
private readonly handlePlaybackEnded = (): void => {
this.config.emitPlaybackEnded();
};
private readonly handleReady = (): void => {
const startTime = this.config.getStartTime();
if (startTime > 0) {
this.config.player.seek = startTime;
}
};
private readonly handleTimeUpdate = (): void => {
const player = this.config.player;
this.config.emitTimeUpdate({
currentTime: player.currentTime,
duration: this.config.getDuration(),
});
};
private readonly nativeListeners: ReadonlyArray<
readonly [event: string, listener: EventListener]
> = [
['error', this.handleNativePlaybackError],
['loadeddata', this.clearPlaybackIssue],
['playing', this.clearPlaybackIssue],
['volumechange', this.handleVolumeChange],
['ended', this.handlePlaybackEnded],
];
constructor(private readonly config: ArtPlayerVideoSessionConfig) {}
attach(): void {
if (this.attached || this.destroyed) {
return;
}
this.attached = true;
for (const [event, listener] of this.nativeListeners) {
this.config.player.video.addEventListener(event, listener);
}
this.config.player.on('ready', this.handleReady);
this.config.player.on('video:timeupdate', this.handleTimeUpdate);
}
destroy(): void {
if (!this.attached || this.destroyed) {
return;
}
this.destroyed = true;
this.attached = false;
for (const [event, listener] of this.nativeListeners) {
this.config.player.video.removeEventListener(event, listener);
}
this.config.player.off('ready', this.handleReady);
this.config.player.off('video:timeupdate', this.handleTimeUpdate);
}
}
@@ -1,9 +1,25 @@
<div class="art-player-shell">
<div
#playerRoot
class="art-player-shell"
[class.art-player-shell--shared-controls]="sharedControls"
>
<div #artplayer class="artplayer-container"></div>
<app-series-playback-navigation-controls
[navigation]="seriesNavigation"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
@if (sharedControls) {
<div class="art-player-interaction-capture" aria-hidden="true"></div>
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @else {
<app-series-playback-navigation-controls
[navigation]="seriesNavigation()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
</div>
@@ -2,6 +2,8 @@
display: block;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.art-player-shell {
@@ -16,3 +18,40 @@
width: 100%;
height: 100%;
}
.art-player-shell--shared-controls {
isolation: isolate;
.artplayer-container {
position: relative;
z-index: 0;
isolation: isolate;
}
.art-player-interaction-capture {
position: absolute;
z-index: 1;
inset: 0;
pointer-events: auto;
}
// ArtPlayer's flags remove optional widgets, but its core mask, bottom bar,
// menus, notices and loading layer remain mounted. Scope all suppression to
// the rollout class so the default-off vendor skin is untouched.
::ng-deep .art-video-player {
.art-bottom,
.art-mask,
.art-settings,
.art-contextmenus,
.art-info,
.art-notice,
.art-loading,
.art-layers {
display: none !important;
}
&.art-control-show .art-subtitle {
bottom: var(--art-subtitle-bottom);
}
}
}
@@ -0,0 +1,309 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import type { Channel } from '@iptvnator/shared/interfaces';
import {
PlayerControlsComponent,
WEB_PLAYER_SHARED_CONTROLS,
} from '../player-controls';
import type { ArtPlayerComponent as ArtPlayerComponentInstance } from './art-player.component';
const artPlayerInstances: MockArtplayer[] = [];
class MockArtplayer {
static AUTO_PLAYBACK_TIMEOUT = 0;
readonly video = document.createElement('video');
readonly setting = { add: jest.fn() };
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);
return this;
}
);
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)
);
return this;
}
);
readonly destroy = jest.fn();
currentTime = 0;
duration = 0;
seek = 0;
constructor(readonly options: Record<string, unknown>) {
this.video.volume = Number(options['volume'] ?? 1);
const stored = JSON.parse(
localStorage.getItem('artplayer_settings') ?? '{}'
) as { volume?: unknown };
if (typeof stored.volume === 'number') {
this.video.volume = stored.volume;
}
artPlayerInstances.push(this);
}
get volume(): number {
return this.video.volume;
}
set volume(value: number) {
this.video.volume = value;
}
}
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);
}
jest.unstable_mockModule('artplayer', () => ({
default: MockArtplayer,
}));
jest.unstable_mockModule('hls.js', () => ({
default: MockHls,
}));
jest.unstable_mockModule('mpegts.js', () => ({
default: {
Events: { ERROR: 'error' },
createPlayer: jest.fn(),
isSupported: jest.fn(() => true),
},
}));
describe('ArtPlayerComponent with shared controls', () => {
let ArtPlayerComponent: typeof import('./art-player.component').ArtPlayerComponent;
let fixture: ComponentFixture<ArtPlayerComponentInstance>;
let component: ArtPlayerComponentInstance;
beforeAll(async () => {
({ ArtPlayerComponent } = await import('./art-player.component'));
});
beforeEach(() => {
artPlayerInstances.length = 0;
localStorage.clear();
TestBed.configureTestingModule({
imports: [ArtPlayerComponent, TranslateModule.forRoot()],
providers: [
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true },
],
});
});
afterEach(() => {
fixture?.destroy();
});
it('renders exactly one shared controls surface and disables vendor interaction owners', () => {
createComponent({
url: 'https://example.test/movie.mp4',
name: 'Movie',
});
const options = artPlayerInstances[0].options;
expect(options).toEqual(
expect.objectContaining({
controls: [],
autoPlayback: false,
autoSize: false,
autoMini: false,
setting: false,
fullscreen: false,
fullscreenWeb: false,
hotkey: false,
fastForward: false,
autoOrientation: false,
})
);
expect(
fixture.debugElement.queryAll(By.css('app-player-controls'))
).toHaveLength(1);
expect(
fixture.debugElement.query(
By.css('.art-player-interaction-capture')
)
).not.toBeNull();
expect(
fixture.debugElement.query(
By.css('app-series-playback-navigation-controls')
)
).toBeNull();
expect(
fixture.debugElement
.query(By.css('.art-player-shell'))
.nativeElement.classList.contains(
'art-player-shell--shared-controls'
)
).toBe(true);
});
it('reapplies the app volume after ArtPlayer restores vendor storage', () => {
localStorage.setItem(
'artplayer_settings',
JSON.stringify({ volume: 0.2 })
);
createComponent(
{
url: 'https://example.test/movie.mp4',
name: 'Movie',
},
{ volume: 0.8 }
);
expect(artPlayerInstances[0].video.volume).toBe(0.8);
});
it('keeps the vendor event capture active while diagnostics disable shared interaction', () => {
createComponent({
url: 'https://example.test/movie.mp4',
name: 'Movie',
});
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
const controls = fixture.debugElement.query(
By.directive(PlayerControlsComponent)
).componentInstance as PlayerControlsComponent;
expect(controls.showControls()).toBe(false);
expect(controls.shortcutsEnabled()).toBe(false);
expect(
fixture.debugElement.query(
By.css('.art-player-interaction-capture')
)
).not.toBeNull();
});
it('updates shared series navigation without recreating the player', () => {
createComponent(
{
url: 'https://example.test/series/episode.mp4',
name: 'Episode',
},
{ isLive: false }
);
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: true,
canNext: false,
autoplayEnabled: true,
});
fixture.detectChanges();
expect(component.controlsAdapter.capabilities().seriesNavigation).toBe(
true
);
expect(component.controlsAdapter.state()).toEqual(
expect.objectContaining({
canPreviousEpisode: true,
canNextEpisode: false,
})
);
expect(artPlayerInstances).toHaveLength(1);
});
it('restarts the shared ArtPlayer session when authoritative live metadata changes', () => {
createComponent(
{
url: 'https://example.test/movie.ts',
name: 'Movie',
},
{ isLive: true }
);
fixture.componentRef.setInput('isLive', false);
fixture.detectChanges();
expect(artPlayerInstances).toHaveLength(2);
expect(artPlayerInstances[1].options['isLive']).toBe(false);
expect(artPlayerInstances[0].destroy).toHaveBeenCalledTimes(1);
});
it('exits only the ArtPlayer shell fullscreen when interaction is disabled', () => {
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
document,
'fullscreenElement'
);
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
);
let fullscreenElement: Element | null = null;
const exitFullscreen = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
try {
createComponent({
url: 'https://example.test/movie.mp4',
name: 'Movie',
});
fullscreenElement = fixture.debugElement.query(
By.css('.art-player-shell')
).nativeElement;
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
} finally {
restoreDocumentProperty(
'fullscreenElement',
fullscreenElementDescriptor
);
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
}
});
function createComponent(
channel: Pick<Channel, 'url' | 'name'>,
inputs: { volume?: number; isLive?: boolean } = {}
): void {
fixture = TestBed.createComponent(ArtPlayerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('channel', channel);
fixture.componentRef.setInput('volume', inputs.volume ?? 1);
fixture.componentRef.setInput('isLive', inputs.isLive ?? true);
fixture.detectChanges();
}
});
function restoreDocumentProperty(
property: 'exitFullscreen' | 'fullscreenElement',
descriptor: PropertyDescriptor | undefined
): void {
if (descriptor) {
Object.defineProperty(document, property, descriptor);
return;
}
delete (document as unknown as Record<string, unknown>)[property];
}
@@ -0,0 +1,111 @@
export const artPlayerInstances: MockArtplayer[] = [];
export const hlsInstances: MockHls[] = [];
export const mpegTsInstances: MockMpegTsPlayer[] = [];
export class MockArtplayer {
static AUTO_PLAYBACK_TIMEOUT = 0;
readonly video = document.createElement('video');
readonly setting = { add: jest.fn() };
readonly on = jest.fn();
readonly off = jest.fn();
readonly destroy = jest.fn();
readonly currentTime = 0;
readonly duration = 0;
seek = 0;
volume: number;
constructor(readonly options: Record<string, unknown>) {
this.volume = Number(options['volume'] ?? 1);
const stored = JSON.parse(
localStorage.getItem('artplayer_settings') ?? '{}'
) as { volume?: unknown };
if (typeof stored.volume === 'number') {
this.volume = stored.volume;
}
artPlayerInstances.push(this);
}
}
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, (...args: unknown[]) => void>();
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 loadSource = jest.fn();
readonly attachMedia = jest.fn();
readonly destroy = jest.fn();
readonly audioTracks: unknown[] = [];
constructor() {
hlsInstances.push(this);
}
}
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();
readonly pause = jest.fn();
readonly unload = jest.fn();
readonly detachMediaElement = jest.fn();
readonly destroy = jest.fn();
constructor() {
mpegTsInstances.push(this);
}
}
export function resetArtPlayerSpecFixtures(): void {
artPlayerInstances.length = 0;
hlsInstances.length = 0;
mpegTsInstances.length = 0;
}
export function getCustomType(
type: 'm3u8' | 'ts' | 'mkv'
): (video: HTMLVideoElement, url: string) => void {
const customType = (
artPlayerInstances[0].options['customType'] as Record<
string,
(video: HTMLVideoElement, url: string, art: MockArtplayer) => void
>
)[type];
return (video, url) => customType(video, url, artPlayerInstances[0]);
}
@@ -1,73 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Channel } from '@iptvnator/shared/interfaces';
import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls';
import type { ArtPlayerComponent as ArtPlayerComponentInstance } from './art-player.component';
const artPlayerInstances: MockArtplayer[] = [];
const hlsInstances: MockHls[] = [];
const mpegTsInstances: MockMpegTsPlayer[] = [];
class MockArtplayer {
static AUTO_PLAYBACK_TIMEOUT = 0;
readonly video = document.createElement('video');
readonly setting = { add: jest.fn() };
readonly on = jest.fn();
readonly destroy = jest.fn();
readonly currentTime = 0;
readonly duration = 0;
volume: number;
constructor(readonly options: Record<string, unknown>) {
this.volume = Number(options['volume'] ?? 1);
artPlayerInstances.push(this);
}
}
class MockHls {
static Events = {
MANIFEST_PARSED: 'manifestParsed',
ERROR: 'error',
AUDIO_TRACKS_UPDATED: 'audioTracksUpdated',
};
static isSupported = jest.fn(() => true);
readonly handlers = new Map<string, (...args: unknown[]) => void>();
readonly on = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
this.handlers.set(event, handler);
}
);
readonly loadSource = jest.fn();
readonly attachMedia = jest.fn();
readonly destroy = jest.fn();
readonly audioTracks: unknown[] = [];
constructor() {
hlsInstances.push(this);
}
}
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 load = jest.fn();
readonly play = jest.fn();
readonly pause = jest.fn();
readonly unload = jest.fn();
readonly detachMediaElement = jest.fn();
readonly destroy = jest.fn();
constructor() {
mpegTsInstances.push(this);
}
}
import {
MockArtplayer,
MockHls,
MockMpegTsPlayer,
artPlayerInstances,
getCustomType,
hlsInstances,
mpegTsInstances,
resetArtPlayerSpecFixtures,
} from './art-player.component.spec-fixtures';
jest.unstable_mockModule('artplayer', () => ({
default: MockArtplayer,
@@ -97,12 +42,14 @@ describe('ArtPlayerComponent', () => {
});
beforeEach(() => {
artPlayerInstances.length = 0;
hlsInstances.length = 0;
mpegTsInstances.length = 0;
resetArtPlayerSpecFixtures();
localStorage.clear();
TestBed.configureTestingModule({
imports: [ArtPlayerComponent],
providers: [
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: false },
],
});
});
@@ -128,7 +75,7 @@ describe('ArtPlayerComponent', () => {
},
});
getCustomType('mkv')(
getCustomType('video/matroska')(
artPlayerInstances[0].video,
'https://example.com/archive/movie.mkv'
);
@@ -224,6 +171,54 @@ describe('ArtPlayerComponent', () => {
expect(artPlayerInstances).toHaveLength(1);
});
it('applies a simultaneous volume change after rebuilding the legacy player', () => {
localStorage.setItem(
'artplayer_settings',
JSON.stringify({ volume: 0.2 })
);
createComponent({
url: 'https://example.com/live/first.m3u8',
name: 'First channel',
});
fixture.componentRef.setInput('channel', {
url: 'https://example.com/live/second.m3u8',
name: 'Second channel',
});
fixture.componentRef.setInput('volume', 0.73);
fixture.detectChanges();
expect(artPlayerInstances).toHaveLength(2);
expect(artPlayerInstances[1].volume).toBe(0.73);
});
it('keeps the complete ArtPlayer skin and legacy series controls when the flag is off', () => {
createComponent({
url: 'https://example.com/movie.mp4',
name: 'Movie',
});
expect(artPlayerInstances[0].options).toEqual(
expect.objectContaining({
autoPlayback: true,
autoSize: true,
autoMini: true,
setting: true,
fullscreen: true,
fullscreenWeb: true,
})
);
expect(artPlayerInstances[0].options['hotkey']).toBeUndefined();
expect(
fixture.debugElement.query(By.css('app-player-controls'))
).toBeNull();
expect(
fixture.debugElement.query(
By.css('.art-player-interaction-capture')
)
).toBeNull();
});
it('emits a playback issue when mpegts.js reports an unsupported codec', () => {
createComponent({
url: 'https://example.com/live/channel.ts',
@@ -351,15 +346,4 @@ describe('ArtPlayerComponent', () => {
fixture.componentRef.setInput('channel', channel);
fixture.detectChanges();
}
function getCustomType(
type: 'm3u8' | 'ts' | 'mkv'
): (video: HTMLVideoElement, url: string) => void {
return (
artPlayerInstances[0].options['customType'] as Record<
string,
(video: HTMLVideoElement, url: string) => void
>
)[type];
}
});
@@ -1,256 +1,211 @@
import {
Component,
ElementRef,
EventEmitter,
inject,
Input,
input,
OnChanges,
OnDestroy,
OnInit,
Output,
output,
signal,
SimpleChanges,
viewChild,
} from '@angular/core';
import Artplayer from 'artplayer';
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
import mpegts from 'mpegts.js';
import { Channel } from '@iptvnator/shared/interfaces';
import { addHlsAudioTrackSettings } from './art-player-audio-tracks';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
classifyHlsPlaybackIssue,
classifyMpegTsPlaybackIssue,
classifyNativePlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
PlayerControlsComponent,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} 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 {
buildArtPlayerChrome,
exitOwnedArtPlayerFullscreen,
getArtPlayerVideoType,
resolveArtPlayerIsLive,
} from './art-player-setup';
import { ArtPlayerSourceSession } from './art-player-source-session';
import { ArtPlayerVideoSession } from './art-player-video-session';
const debugArtPlayer = createDevLogger('ArtPlayer');
Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000;
@Component({
selector: 'app-art-player',
imports: [SeriesPlaybackNavigationControlsComponent],
imports: [
PlayerControlsComponent,
SeriesPlaybackNavigationControlsComponent,
],
providers: [WebVideoControlsAdapter],
templateUrl: './art-player.component.html',
styleUrls: ['./art-player.component.scss'],
})
export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
@Input() channel!: Channel;
@Input() volume = 1;
@Input() showCaptions = false;
@Input() startTime = 0;
@Input() seriesNavigation: SeriesPlaybackNavigation | null = null;
@Output() timeUpdate = new EventEmitter<{
readonly channel = input.required<Channel>();
readonly volume = input(1);
readonly showCaptions = input(false);
readonly startTime = input(0);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
@Output() playbackEnded = new EventEmitter<void>();
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
private player!: Artplayer;
private hls: Hls | null = null;
private mpegtsPlayer: mpegts.Player | null = null;
readonly sharedControls = inject(WEB_PLAYER_SHARED_CONTROLS);
readonly controlsAdapter = inject(WebVideoControlsAdapter);
readonly playerRoot = viewChild<ElementRef<HTMLElement>>('playerRoot');
private readonly artplayerContainer =
viewChild.required<ElementRef<HTMLDivElement>>('artplayer');
private readonly seriesNavigationSignal =
signal<SeriesPlaybackNavigation | null>(null);
private readonly elementRef = inject(ElementRef);
private readonly handleNativePlaybackError = () => {
this.playbackIssue.emit(
classifyNativePlaybackIssue(
this.player?.video?.error,
this.createSourceMetadata(
this.channel?.url ?? this.player?.video?.currentSrc ?? ''
)
)
);
};
private readonly clearPlaybackIssue = () => {
this.playbackIssue.emit(null);
};
private readonly handlePlaybackEnded = () => {
this.playbackEnded.emit();
};
private player: Artplayer | null = null;
private sourceSession: ArtPlayerSourceSession | null = null;
private videoSession: ArtPlayerVideoSession | null = null;
ngOnInit(): void {
this.seriesNavigationSignal.set(this.seriesNavigation());
if (this.sharedControls) {
this.controlsAdapter.setContext({
seriesNavigation: this.seriesNavigationSignal,
});
}
this.initPlayer();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['seriesNavigation']) {
this.seriesNavigationSignal.set(this.seriesNavigation());
}
const channelChanged =
changes['channel'] && !changes['channel'].firstChange;
const authoritativeLiveChanged =
this.sharedControls &&
changes['isLive'] &&
!changes['isLive'].firstChange &&
changes['isLive'].previousValue !== changes['isLive'].currentValue;
if (this.player && (channelChanged || authoritativeLiveChanged)) {
this.destroyPlayer();
this.initPlayer();
}
if (changes['showCaptions']) {
this.sourceSession?.refreshInputs();
}
if (changes['interactionEnabled']?.currentValue === false) {
exitOwnedArtPlayerFullscreen(
this.sharedControls,
this.playerRoot()?.nativeElement,
(error) =>
debugArtPlayer(
'Failed to exit ArtPlayer fullscreen:',
error
)
);
}
if (changes['volume']?.currentValue !== undefined && this.player) {
this.applyVolume(changes['volume'].currentValue);
}
}
ngOnDestroy(): void {
this.destroyPlayer();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['channel'] && !changes['channel'].firstChange) {
this.destroyPlayer();
this.initPlayer();
}
if (changes['volume'] && this.player) {
this.applyVolume(changes['volume'].currentValue);
private initPlayer(): void {
this.playbackIssue.emit(null);
const channel = this.channel();
const sourceUrl = channel.url + (channel.epgParams ?? '');
const sourceSession = new ArtPlayerSourceSession({
sharedControls: this.sharedControls,
controlsAdapter: this.controlsAdapter,
isLive: () => this.isLive(),
showCaptions: () => this.showCaptions(),
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
});
this.sourceSession = sourceSession;
const player = new Artplayer({
container: this.artplayerContainer().nativeElement,
url: sourceUrl,
volume: this.clampVolume(this.volume()),
isLive: resolveArtPlayerIsLive(
this.sharedControls,
this.isLive(),
channel.url
),
autoplay: true,
type: getArtPlayerVideoType(channel.url),
playsInline: true,
backdrop: true,
mutex: true,
theme: '#ff0000',
...buildArtPlayerChrome(this.sharedControls),
customType: sourceSession.customType,
});
this.player = player;
sourceSession.attach(player);
const videoSession = new ArtPlayerVideoSession({
player,
sourceUrl: channel.url,
getStartTime: () => this.startTime(),
getDuration: () => sourceSession.resolveDuration(player.duration),
persistSharedVolume: this.sharedControls,
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
emitPlaybackEnded: () => this.playbackEnded.emit(),
});
this.videoSession = videoSession;
videoSession.attach();
// ArtPlayer synchronously restores `artplayer_settings.volume` after
// applying the constructor option. Shared controls use the app-wide
// volume as authoritative, so reapply it directly to the media element.
if (this.sharedControls) {
this.applyVolume(this.volume());
}
}
private destroyPlayer(): void {
if (this.mpegtsPlayer) {
this.mpegtsPlayer.pause();
this.mpegtsPlayer.unload();
this.mpegtsPlayer.detachMediaElement();
this.mpegtsPlayer.destroy();
this.mpegtsPlayer = null;
}
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
if (this.player) {
this.player.video?.removeEventListener(
'error',
this.handleNativePlaybackError
);
this.player.video?.removeEventListener(
'loadeddata',
this.clearPlaybackIssue
);
this.player.video?.removeEventListener(
'playing',
this.clearPlaybackIssue
);
this.player.video?.removeEventListener(
'ended',
this.handlePlaybackEnded
);
this.player.destroy();
}
}
const sourceSession = this.sourceSession;
this.sourceSession = null;
sourceSession?.destroy();
private initPlayer(): void {
this.playbackIssue.emit(null);
const el = this.elementRef.nativeElement.querySelector(
'.artplayer-container'
);
const extension = getPlaybackMediaExtensionFromUrl(
this.channel?.url ?? ''
);
const isLive = extension === 'm3u8' || extension === 'ts' || !extension;
const videoSession = this.videoSession;
this.videoSession = null;
videoSession?.destroy();
this.player = new Artplayer({
container: el,
url: this.channel.url + (this.channel.epgParams || ''),
volume: this.clampVolume(this.volume),
isLive: isLive,
autoplay: true,
type: this.getVideoType(this.channel.url),
pip: true,
autoPlayback: true,
autoSize: true,
autoMini: true,
screenshot: true,
setting: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
fullscreenWeb: true,
playsInline: true,
airplay: true,
backdrop: true,
mutex: true,
theme: '#ff0000',
customType: {
m3u8: (video: HTMLVideoElement, url: string) => {
if (Hls.isSupported()) {
if (this.hls) {
this.hls.destroy();
}
this.hls = new Hls();
this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
this.handleHlsManifestParsed(url, data);
});
this.hls.on(Hls.Events.ERROR, (_, data) => {
this.handleHlsError(url, data);
});
this.hls.loadSource(url);
this.hls.attachMedia(video);
addHlsAudioTrackSettings(this.player, this.hls);
} else if (
video.canPlayType('application/vnd.apple.mpegurl')
) {
video.src = url;
}
},
ts: (video: HTMLVideoElement, url: string) => {
if (mpegts.isSupported()) {
if (this.mpegtsPlayer) {
this.mpegtsPlayer.destroy();
}
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
isLive: true,
url: url,
});
this.mpegtsPlayer.attachMediaElement(video);
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'
)
)
);
}
);
this.mpegtsPlayer.load();
this.mpegtsPlayer.play();
}
},
mkv: (video: HTMLVideoElement, url: string) => {
video.src = url;
},
},
});
this.player.video.addEventListener(
'error',
this.handleNativePlaybackError
);
this.player.video.addEventListener(
'loadeddata',
this.clearPlaybackIssue
);
this.player.video.addEventListener('playing', this.clearPlaybackIssue);
this.player.video.addEventListener('ended', this.handlePlaybackEnded);
if (this.startTime > 0) {
this.player.on('ready', () => {
this.player.seek = this.startTime;
});
}
this.player.on('video:timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime,
duration: this.player.duration,
});
});
const player = this.player;
this.player = null;
player?.destroy();
}
private applyVolume(value: number): void {
this.player.volume = this.clampVolume(value);
const player = this.player;
if (!player) {
return;
}
const volume = this.clampVolume(value);
if (this.sharedControls) {
player.video.volume = volume;
player.video.muted = volume <= 0;
} else {
player.volume = volume;
}
}
private clampVolume(value: number): number {
@@ -258,79 +213,6 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
if (!Number.isFinite(numericValue)) {
return 1;
}
return Math.max(0, Math.min(1, numericValue));
}
private handleHlsManifestParsed(
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))
);
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')
)
);
}
private getVideoType(url: string): string {
const extension = getPlaybackMediaExtensionFromUrl(url);
switch (extension) {
case 'mkv':
return 'video/matroska';
case 'm3u8':
return 'm3u8';
case 'mp4':
return 'mp4';
case 'ts':
return 'ts';
default:
// No recognized extension (e.g. IPTV proxy URL) → default to
// MPEG-TS which is the most common format for live IPTV streams.
return extension ? 'auto' : 'ts';
}
}
private createSourceMetadata(
url: string,
mimeType?: string,
audioCodecs: readonly string[] = [],
videoCodecs: readonly string[] = []
) {
return createPlaybackSourceMetadata({
url,
mimeType,
player: InlinePlaybackPlayer.ArtPlayer,
audioCodecs,
videoCodecs,
});
}
}
@@ -1,190 +1,5 @@
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 { HtmlVideoPlayerHlsControls } from './html-video-player-hls-controls';
import { HtmlVideoPlayerNativeTextTracks } from './html-video-player-native-text-tracks';
export type HtmlVideoControlsSource =
| { kind: 'native' }
| { kind: 'mpegts' }
| { kind: 'hls'; hls: Hls };
export interface HtmlVideoPlayerControlsBridgeConfig {
video: HTMLVideoElement;
adapter: WebVideoControlsAdapter;
isLive: () => boolean;
showCaptions: () => boolean;
}
export class HtmlVideoPlayerControlsBridge {
private readonly config: HtmlVideoPlayerControlsBridgeConfig;
private readonly hlsControls: HtmlVideoPlayerHlsControls;
private readonly nativeTextTracks: HtmlVideoPlayerNativeTextTracks;
private source: HtmlVideoControlsSource | null = null;
private attached = false;
private destroyed = false;
constructor(config: HtmlVideoPlayerControlsBridgeConfig) {
this.config = config;
const refresh = () => this.config.adapter.refresh();
this.hlsControls = new HtmlVideoPlayerHlsControls({
showCaptions: config.showCaptions,
refresh,
});
this.nativeTextTracks = new HtmlVideoPlayerNativeTextTracks({
video: config.video,
showCaptions: config.showCaptions,
refresh,
});
}
attach(): void {
if (this.attached || this.destroyed) {
return;
}
this.config.adapter.attach(this.config.video, {
isLive: this.config.isLive,
getDuration: () => this.readDuration(),
getAudioTracks: () => this.getAudioTracks(),
setAudioTrack: (id) => this.setAudioTrack(id),
getSubtitleTracks: () => this.getSubtitleTracks(),
setSubtitleTrack: (id) => this.setSubtitleTrack(id),
});
this.attached = true;
}
setSource(source: HtmlVideoControlsSource): void {
if (this.destroyed) {
return;
}
this.clearActiveSource();
this.source = source;
if (source.kind === 'hls') {
this.hlsControls.bind(source.hls);
} else {
this.nativeTextTracks.bind();
}
this.config.adapter.refresh();
}
refreshInputs(): void {
if (this.destroyed) {
return;
}
if (this.source?.kind === 'hls') {
this.hlsControls.refreshInputs();
} else if (this.source) {
this.nativeTextTracks.refreshInputs();
}
this.config.adapter.refresh();
}
clearSource(): void {
if (this.destroyed) {
return;
}
this.clearActiveSource();
this.source = null;
this.config.adapter.refresh();
}
destroy(): void {
if (this.destroyed) {
return;
}
this.clearSource();
if (this.attached) {
this.config.adapter.detach();
this.attached = false;
}
this.destroyed = true;
}
private getAudioTracks(): PlayerTrack[] {
return this.source?.kind === 'hls'
? this.hlsControls.getAudioTracks()
: [];
}
private setAudioTrack(id: number): void {
if (this.source?.kind === 'hls') {
this.hlsControls.setAudioTrack(id);
}
}
private getSubtitleTracks(): PlayerTrack[] {
if (this.source?.kind === 'hls') {
return this.hlsControls.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) {
this.nativeTextTracks.setSubtitleTrack(id);
}
}
private clearActiveSource(): void {
if (this.source?.kind === 'hls') {
this.hlsControls.clear();
} else if (this.source) {
this.nativeTextTracks.clear();
}
}
private readDuration(): number {
if (this.source?.kind !== 'mpegts' || this.config.isLive()) {
return NaN;
}
const videoDuration = this.readFinitePositive(() => {
return this.config.video.duration;
});
if (!Number.isNaN(videoDuration)) {
return videoDuration;
}
const seekableEnd = this.readLastFinitePositiveEnd(() => {
return this.config.video.seekable;
});
if (!Number.isNaN(seekableEnd)) {
return seekableEnd;
}
return this.readLastFinitePositiveEnd(() => {
return this.config.video.buffered;
});
}
private readFinitePositive(read: () => number): number {
try {
const value = read();
return Number.isFinite(value) && value > 0 ? value : NaN;
} catch {
return NaN;
}
}
private readLastFinitePositiveEnd(read: () => TimeRanges): number {
try {
const ranges = read();
for (let index = ranges.length - 1; index >= 0; index -= 1) {
const end = ranges.end(index);
if (Number.isFinite(end) && end > 0) {
return end;
}
}
} catch {
return NaN;
}
return NaN;
}
}
export { WebVideoSourceControlsBridge as HtmlVideoPlayerControlsBridge } from '../web-video-support/web-video-source-controls.bridge';
export type {
WebVideoControlsSource as HtmlVideoControlsSource,
WebVideoSourceControlsBridgeConfig as HtmlVideoPlayerControlsBridgeConfig,
} from '../web-video-support/web-video-source-controls.bridge';
@@ -1,175 +1,2 @@
import Hls from 'hls.js';
import type { PlayerTrack } from '../player-controls/player-controls.model';
export interface HtmlVideoPlayerHlsControlsConfig {
showCaptions: () => boolean;
refresh: () => void;
}
export class HtmlVideoPlayerHlsControls {
private hls: Hls | null = null;
private refreshListener: (() => void) | null = null;
private subtitleOverride: number | null = null;
private suppressedSubtitleTrack: number | null = null;
constructor(private readonly config: HtmlVideoPlayerHlsControlsConfig) {}
bind(hls: Hls): void {
this.clear();
this.hls = hls;
const refresh = () => {
this.applyCaptionState();
this.config.refresh();
};
this.refreshListener = refresh;
for (const event of getHlsRefreshEvents()) {
hls.on(event, refresh);
}
this.applyCaptionState();
}
clear(): void {
if (this.hls && this.refreshListener) {
for (const event of getHlsRefreshEvents()) {
this.hls.off(event, this.refreshListener);
}
}
this.hls = null;
this.refreshListener = null;
this.subtitleOverride = null;
this.suppressedSubtitleTrack = null;
}
refreshInputs(): void {
this.applyCaptionState();
}
getAudioTracks(): PlayerTrack[] {
if (!this.hls) {
return [];
}
const hls = this.hls;
return hls.audioTracks.map((track, index) => ({
id: index,
label: track.name || track.lang || `Audio ${index + 1}`,
selected: index === hls.audioTrack,
}));
}
setAudioTrack(id: number): void {
if (
this.hls &&
Number.isInteger(id) &&
id >= 0 &&
id < this.hls.audioTracks.length
) {
this.hls.audioTrack = id;
}
}
getSubtitleTracks(): PlayerTrack[] {
if (!this.hls) {
return [];
}
const hls = this.hls;
return hls.subtitleTracks.map((track, index) => ({
id: index,
label: track.name || track.lang || `Subtitle ${index + 1}`,
selected:
hls.subtitleDisplay === true && index === hls.subtitleTrack,
}));
}
setSubtitleTrack(id: number): void {
if (!this.hls || !Number.isInteger(id)) {
return;
}
if (id === -1) {
this.subtitleOverride = -1;
this.suppressedSubtitleTrack = null;
this.setSubtitleTrackValue(-1);
this.setSubtitleDisplay(false);
return;
}
if (id < 0 || id >= this.hls.subtitleTracks.length) {
return;
}
this.subtitleOverride = id;
this.suppressedSubtitleTrack = null;
this.setSubtitleDisplay(true);
this.setSubtitleTrackValue(id);
}
private applyCaptionState(): void {
if (!this.hls) {
return;
}
if (this.subtitleOverride !== null) {
if (this.subtitleOverride === -1) {
this.setSubtitleTrackValue(-1);
this.setSubtitleDisplay(false);
return;
}
if (this.subtitleOverride < this.hls.subtitleTracks.length) {
this.setSubtitleDisplay(true);
this.setSubtitleTrackValue(this.subtitleOverride);
} else {
this.setSubtitleDisplay(false);
}
return;
}
if (!this.config.showCaptions()) {
// HLS may choose its default track while display is suppressed.
// Explicit user-off remains owned by subtitleOverride === -1 above.
if (
Number.isInteger(this.hls.subtitleTrack) &&
this.hls.subtitleTrack >= 0 &&
this.hls.subtitleTrack < this.hls.subtitleTracks.length
) {
this.suppressedSubtitleTrack = this.hls.subtitleTrack;
}
this.setSubtitleDisplay(false);
return;
}
const suppressedTrack = this.suppressedSubtitleTrack;
if (
suppressedTrack !== null &&
suppressedTrack < this.hls.subtitleTracks.length
) {
this.suppressedSubtitleTrack = null;
this.setSubtitleDisplay(true);
this.setSubtitleTrackValue(suppressedTrack);
}
}
private setSubtitleTrackValue(id: number): void {
if (this.hls && this.hls.subtitleTrack !== id) {
this.hls.subtitleTrack = id;
}
}
private setSubtitleDisplay(display: boolean): void {
if (this.hls && this.hls.subtitleDisplay !== display) {
this.hls.subtitleDisplay = display;
}
}
}
function getHlsRefreshEvents() {
return [
Hls.Events.AUDIO_TRACKS_UPDATED,
Hls.Events.AUDIO_TRACK_SWITCHING,
Hls.Events.AUDIO_TRACK_SWITCHED,
Hls.Events.SUBTITLE_TRACKS_UPDATED,
Hls.Events.SUBTITLE_TRACKS_CLEARED,
Hls.Events.SUBTITLE_TRACK_SWITCH,
Hls.Events.MANIFEST_LOADING,
] as const;
}
export { WebVideoHlsControls as HtmlVideoPlayerHlsControls } from '../web-video-support/web-video-hls-controls';
export type { WebVideoHlsControlsConfig as HtmlVideoPlayerHlsControlsConfig } from '../web-video-support/web-video-hls-controls';
@@ -1,173 +1,2 @@
import type { PlayerTrack } from '../player-controls/player-controls.model';
const NATIVE_TRACK_EVENTS = ['addtrack', 'removetrack', 'change'] as const;
interface NativeSubtitleTrack {
id: number;
track: TextTrack;
}
export interface HtmlVideoPlayerNativeTextTracksConfig {
video: HTMLVideoElement;
showCaptions: () => boolean;
refresh: () => void;
}
export class HtmlVideoPlayerNativeTextTracks {
private textTrackList: TextTrackList | null = null;
private refreshListener: EventListener | null = null;
private trackIds = new WeakMap<TextTrack, number>();
private suppressedModes = new WeakMap<TextTrack, TextTrackMode>();
private nextTrackId = 0;
private subtitleOverride: number | null = null;
private active = false;
constructor(
private readonly config: HtmlVideoPlayerNativeTextTracksConfig
) {}
bind(): void {
this.clear();
this.active = true;
const textTracks = this.config.video.textTracks;
if (typeof textTracks.addEventListener === 'function') {
const refresh: EventListener = () => {
this.applyCaptionState();
this.config.refresh();
};
this.textTrackList = textTracks;
this.refreshListener = refresh;
for (const event of NATIVE_TRACK_EVENTS) {
textTracks.addEventListener(event, refresh);
}
}
this.applyCaptionState();
}
clear(): void {
if (
this.textTrackList &&
this.refreshListener &&
typeof this.textTrackList.removeEventListener === 'function'
) {
for (const event of NATIVE_TRACK_EVENTS) {
this.textTrackList.removeEventListener(
event,
this.refreshListener
);
}
}
this.textTrackList = null;
this.refreshListener = null;
this.trackIds = new WeakMap<TextTrack, number>();
this.suppressedModes = new WeakMap<TextTrack, TextTrackMode>();
this.nextTrackId = 0;
this.subtitleOverride = null;
this.active = false;
}
refreshInputs(): void {
this.applyCaptionState();
}
getSubtitleTracks(): PlayerTrack[] {
return this.readSubtitleTracks().map(({ id, track }) => ({
id,
label: track.label || track.language || `Subtitle ${id + 1}`,
selected: track.mode === 'showing',
}));
}
setSubtitleTrack(id: number): void {
if (!Number.isInteger(id)) {
return;
}
const tracks = this.readSubtitleTracks();
if (id === -1) {
this.subtitleOverride = -1;
this.applyOverride(tracks);
return;
}
if (!tracks.some((entry) => entry.id === id)) {
return;
}
this.subtitleOverride = id;
this.applyOverride(tracks);
}
private readSubtitleTracks(): NativeSubtitleTrack[] {
if (!this.active) {
return [];
}
const result: NativeSubtitleTrack[] = [];
try {
const tracks = this.config.video.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
if (
!track ||
(track.kind !== 'captions' && track.kind !== 'subtitles')
) {
continue;
}
let id = this.trackIds.get(track);
if (id === undefined) {
id = this.nextTrackId;
this.nextTrackId += 1;
this.trackIds.set(track, id);
}
result.push({ id, track });
}
} catch {
return [];
}
return result;
}
private applyCaptionState(): void {
if (!this.active) {
return;
}
const tracks = this.readSubtitleTracks();
if (this.subtitleOverride !== null) {
this.applyOverride(tracks);
return;
}
if (!this.config.showCaptions()) {
for (const { track } of tracks) {
if (track.mode !== 'showing') {
continue;
}
if (!this.suppressedModes.has(track)) {
this.suppressedModes.set(track, track.mode);
}
track.mode = 'hidden';
}
return;
}
for (const { track } of tracks) {
const suppressedMode = this.suppressedModes.get(track);
if (suppressedMode === undefined) {
continue;
}
track.mode = suppressedMode;
this.suppressedModes.delete(track);
}
}
private applyOverride(tracks: NativeSubtitleTrack[]): void {
for (const { id, track } of tracks) {
track.mode =
this.subtitleOverride !== -1 && id === this.subtitleOverride
? 'showing'
: 'hidden';
}
}
}
export { WebVideoNativeTextTracks as HtmlVideoPlayerNativeTextTracks } from '../web-video-support/web-video-native-text-tracks';
export type { WebVideoNativeTextTracksConfig as HtmlVideoPlayerNativeTextTracksConfig } from '../web-video-support/web-video-native-text-tracks';
@@ -2,12 +2,11 @@ import { InjectionToken } from '@angular/core';
/**
* Reserved rollout switch for shared `app-player-controls` chrome on the web
* video engines (Video.js, html5+hls.js, ArtPlayer).
* video engines (HTML5+hls.js, Video.js, ArtPlayer).
*
* DEFAULT OFF. The built-in HTML5 and Video.js players consume the injectable
* {@link WEB_PLAYER_SHARED_CONTROLS} token and switch atomically between their
* existing chrome and shared controls. ArtPlayer does not consume the token
* yet.
* DEFAULT OFF. The built-in HTML5, Video.js, and ArtPlayer implementations
* consume the injectable {@link WEB_PLAYER_SHARED_CONTROLS} token and switch
* atomically between their existing chrome and shared controls.
*/
export const WEB_PLAYER_SHARED_CONTROLS_ENABLED = false;
@@ -41,6 +41,8 @@
[channel]="$any(channel)"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
@@ -67,6 +67,8 @@ class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
@@ -181,6 +183,24 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
expect(vjsPlayer.interactionEnabled()).toBe(true);
});
it('passes resolved playback metadata and diagnostic interaction state to ArtPlayer', async () => {
fixture.componentRef.setInput('showCaptions', true);
const artPlayer = await renderArtPlayer({ isLive: false });
expect(artPlayer.isLive()).toBe(false);
expect(artPlayer.showCaptions()).toBe(true);
expect(artPlayer.interactionEnabled()).toBe(true);
component.handlePlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(artPlayer.interactionEnabled()).toBe(false);
component.handlePlaybackIssue(null);
fixture.detectChanges();
expect(artPlayer.interactionEnabled()).toBe(true);
});
it('disables HTML5 surface interaction while a diagnostic is visible', async () => {
const htmlPlayer = await renderHtmlPlayer();
@@ -298,6 +318,19 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
return fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
.componentInstance as StubVjsPlayerComponent;
}
async function renderArtPlayer(
metadata: Partial<ResolvedPortalPlayback> = {}
): Promise<StubArtPlayerComponent> {
fixture.componentRef.setInput('playerOverride', VideoPlayer.ArtPlayer);
setPlayback(metadata);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
return fixture.debugElement.query(By.directive(StubArtPlayerComponent))
.componentInstance as StubArtPlayerComponent;
}
});
function createVodContentInfo() {
@@ -74,6 +74,8 @@ class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
@@ -0,0 +1,175 @@
import Hls from 'hls.js';
import type { PlayerTrack } from '../player-controls/player-controls.model';
export interface WebVideoHlsControlsConfig {
showCaptions: () => boolean;
refresh: () => void;
}
export class WebVideoHlsControls {
private hls: Hls | null = null;
private refreshListener: (() => void) | null = null;
private subtitleOverride: number | null = null;
private suppressedSubtitleTrack: number | null = null;
constructor(private readonly config: WebVideoHlsControlsConfig) {}
bind(hls: Hls): void {
this.clear();
this.hls = hls;
const refresh = () => {
this.applyCaptionState();
this.config.refresh();
};
this.refreshListener = refresh;
for (const event of getHlsRefreshEvents()) {
hls.on(event, refresh);
}
this.applyCaptionState();
}
clear(): void {
if (this.hls && this.refreshListener) {
for (const event of getHlsRefreshEvents()) {
this.hls.off(event, this.refreshListener);
}
}
this.hls = null;
this.refreshListener = null;
this.subtitleOverride = null;
this.suppressedSubtitleTrack = null;
}
refreshInputs(): void {
this.applyCaptionState();
}
getAudioTracks(): PlayerTrack[] {
if (!this.hls) {
return [];
}
const hls = this.hls;
return hls.audioTracks.map((track, index) => ({
id: index,
label: track.name || track.lang || `Audio ${index + 1}`,
selected: index === hls.audioTrack,
}));
}
setAudioTrack(id: number): void {
if (
this.hls &&
Number.isInteger(id) &&
id >= 0 &&
id < this.hls.audioTracks.length
) {
this.hls.audioTrack = id;
}
}
getSubtitleTracks(): PlayerTrack[] {
if (!this.hls) {
return [];
}
const hls = this.hls;
return hls.subtitleTracks.map((track, index) => ({
id: index,
label: track.name || track.lang || `Subtitle ${index + 1}`,
selected:
hls.subtitleDisplay === true && index === hls.subtitleTrack,
}));
}
setSubtitleTrack(id: number): void {
if (!this.hls || !Number.isInteger(id)) {
return;
}
if (id === -1) {
this.subtitleOverride = -1;
this.suppressedSubtitleTrack = null;
this.setSubtitleTrackValue(-1);
this.setSubtitleDisplay(false);
return;
}
if (id < 0 || id >= this.hls.subtitleTracks.length) {
return;
}
this.subtitleOverride = id;
this.suppressedSubtitleTrack = null;
this.setSubtitleDisplay(true);
this.setSubtitleTrackValue(id);
}
private applyCaptionState(): void {
if (!this.hls) {
return;
}
if (this.subtitleOverride !== null) {
if (this.subtitleOverride === -1) {
this.setSubtitleTrackValue(-1);
this.setSubtitleDisplay(false);
return;
}
if (this.subtitleOverride < this.hls.subtitleTracks.length) {
this.setSubtitleDisplay(true);
this.setSubtitleTrackValue(this.subtitleOverride);
} else {
this.setSubtitleDisplay(false);
}
return;
}
if (!this.config.showCaptions()) {
// HLS may choose its default track while display is suppressed.
// Explicit user-off remains owned by subtitleOverride === -1 above.
if (
Number.isInteger(this.hls.subtitleTrack) &&
this.hls.subtitleTrack >= 0 &&
this.hls.subtitleTrack < this.hls.subtitleTracks.length
) {
this.suppressedSubtitleTrack = this.hls.subtitleTrack;
}
this.setSubtitleDisplay(false);
return;
}
const suppressedTrack = this.suppressedSubtitleTrack;
if (
suppressedTrack !== null &&
suppressedTrack < this.hls.subtitleTracks.length
) {
this.suppressedSubtitleTrack = null;
this.setSubtitleDisplay(true);
this.setSubtitleTrackValue(suppressedTrack);
}
}
private setSubtitleTrackValue(id: number): void {
if (this.hls && this.hls.subtitleTrack !== id) {
this.hls.subtitleTrack = id;
}
}
private setSubtitleDisplay(display: boolean): void {
if (this.hls && this.hls.subtitleDisplay !== display) {
this.hls.subtitleDisplay = display;
}
}
}
function getHlsRefreshEvents() {
return [
Hls.Events.AUDIO_TRACKS_UPDATED,
Hls.Events.AUDIO_TRACK_SWITCHING,
Hls.Events.AUDIO_TRACK_SWITCHED,
Hls.Events.SUBTITLE_TRACKS_UPDATED,
Hls.Events.SUBTITLE_TRACKS_CLEARED,
Hls.Events.SUBTITLE_TRACK_SWITCH,
Hls.Events.MANIFEST_LOADING,
] as const;
}
@@ -0,0 +1,171 @@
import type { PlayerTrack } from '../player-controls/player-controls.model';
const NATIVE_TRACK_EVENTS = ['addtrack', 'removetrack', 'change'] as const;
interface NativeSubtitleTrack {
id: number;
track: TextTrack;
}
export interface WebVideoNativeTextTracksConfig {
video: HTMLVideoElement;
showCaptions: () => boolean;
refresh: () => void;
}
export class WebVideoNativeTextTracks {
private textTrackList: TextTrackList | null = null;
private refreshListener: EventListener | null = null;
private trackIds = new WeakMap<TextTrack, number>();
private suppressedModes = new WeakMap<TextTrack, TextTrackMode>();
private nextTrackId = 0;
private subtitleOverride: number | null = null;
private active = false;
constructor(private readonly config: WebVideoNativeTextTracksConfig) {}
bind(): void {
this.clear();
this.active = true;
const textTracks = this.config.video.textTracks;
if (typeof textTracks.addEventListener === 'function') {
const refresh: EventListener = () => {
this.applyCaptionState();
this.config.refresh();
};
this.textTrackList = textTracks;
this.refreshListener = refresh;
for (const event of NATIVE_TRACK_EVENTS) {
textTracks.addEventListener(event, refresh);
}
}
this.applyCaptionState();
}
clear(): void {
if (
this.textTrackList &&
this.refreshListener &&
typeof this.textTrackList.removeEventListener === 'function'
) {
for (const event of NATIVE_TRACK_EVENTS) {
this.textTrackList.removeEventListener(
event,
this.refreshListener
);
}
}
this.textTrackList = null;
this.refreshListener = null;
this.trackIds = new WeakMap<TextTrack, number>();
this.suppressedModes = new WeakMap<TextTrack, TextTrackMode>();
this.nextTrackId = 0;
this.subtitleOverride = null;
this.active = false;
}
refreshInputs(): void {
this.applyCaptionState();
}
getSubtitleTracks(): PlayerTrack[] {
return this.readSubtitleTracks().map(({ id, track }) => ({
id,
label: track.label || track.language || `Subtitle ${id + 1}`,
selected: track.mode === 'showing',
}));
}
setSubtitleTrack(id: number): void {
if (!Number.isInteger(id)) {
return;
}
const tracks = this.readSubtitleTracks();
if (id === -1) {
this.subtitleOverride = -1;
this.applyOverride(tracks);
return;
}
if (!tracks.some((entry) => entry.id === id)) {
return;
}
this.subtitleOverride = id;
this.applyOverride(tracks);
}
private readSubtitleTracks(): NativeSubtitleTrack[] {
if (!this.active) {
return [];
}
const result: NativeSubtitleTrack[] = [];
try {
const tracks = this.config.video.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
if (
!track ||
(track.kind !== 'captions' && track.kind !== 'subtitles')
) {
continue;
}
let id = this.trackIds.get(track);
if (id === undefined) {
id = this.nextTrackId;
this.nextTrackId += 1;
this.trackIds.set(track, id);
}
result.push({ id, track });
}
} catch {
return [];
}
return result;
}
private applyCaptionState(): void {
if (!this.active) {
return;
}
const tracks = this.readSubtitleTracks();
if (this.subtitleOverride !== null) {
this.applyOverride(tracks);
return;
}
if (!this.config.showCaptions()) {
for (const { track } of tracks) {
if (track.mode !== 'showing') {
continue;
}
if (!this.suppressedModes.has(track)) {
this.suppressedModes.set(track, track.mode);
}
track.mode = 'hidden';
}
return;
}
for (const { track } of tracks) {
const suppressedMode = this.suppressedModes.get(track);
if (suppressedMode === undefined) {
continue;
}
track.mode = suppressedMode;
this.suppressedModes.delete(track);
}
}
private applyOverride(tracks: NativeSubtitleTrack[]): void {
for (const { id, track } of tracks) {
track.mode =
this.subtitleOverride !== -1 && id === this.subtitleOverride
? 'showing'
: 'hidden';
}
}
}
@@ -0,0 +1,190 @@
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 { WebVideoHlsControls } from './web-video-hls-controls';
import { WebVideoNativeTextTracks } from './web-video-native-text-tracks';
export type WebVideoControlsSource =
| { kind: 'native' }
| { kind: 'mpegts' }
| { kind: 'hls'; hls: Hls };
export interface WebVideoSourceControlsBridgeConfig {
video: HTMLVideoElement;
adapter: WebVideoControlsAdapter;
isLive: () => boolean;
showCaptions: () => boolean;
}
export class WebVideoSourceControlsBridge {
private readonly config: WebVideoSourceControlsBridgeConfig;
private readonly hlsControls: WebVideoHlsControls;
private readonly nativeTextTracks: WebVideoNativeTextTracks;
private source: WebVideoControlsSource | null = null;
private attached = false;
private destroyed = false;
constructor(config: WebVideoSourceControlsBridgeConfig) {
this.config = config;
const refresh = () => this.config.adapter.refresh();
this.hlsControls = new WebVideoHlsControls({
showCaptions: config.showCaptions,
refresh,
});
this.nativeTextTracks = new WebVideoNativeTextTracks({
video: config.video,
showCaptions: config.showCaptions,
refresh,
});
}
attach(): void {
if (this.attached || this.destroyed) {
return;
}
this.config.adapter.attach(this.config.video, {
isLive: this.config.isLive,
getDuration: () => this.readDuration(),
getAudioTracks: () => this.getAudioTracks(),
setAudioTrack: (id) => this.setAudioTrack(id),
getSubtitleTracks: () => this.getSubtitleTracks(),
setSubtitleTrack: (id) => this.setSubtitleTrack(id),
});
this.attached = true;
}
setSource(source: WebVideoControlsSource): void {
if (this.destroyed) {
return;
}
this.clearActiveSource();
this.source = source;
if (source.kind === 'hls') {
this.hlsControls.bind(source.hls);
} else {
this.nativeTextTracks.bind();
}
this.config.adapter.refresh();
}
refreshInputs(): void {
if (this.destroyed) {
return;
}
if (this.source?.kind === 'hls') {
this.hlsControls.refreshInputs();
} else if (this.source) {
this.nativeTextTracks.refreshInputs();
}
this.config.adapter.refresh();
}
clearSource(): void {
if (this.destroyed) {
return;
}
this.clearActiveSource();
this.source = null;
this.config.adapter.refresh();
}
destroy(): void {
if (this.destroyed) {
return;
}
this.clearSource();
if (this.attached) {
this.config.adapter.detach();
this.attached = false;
}
this.destroyed = true;
}
readDuration(): number {
if (this.source?.kind !== 'mpegts' || this.config.isLive()) {
return NaN;
}
const videoDuration = this.readFinitePositive(() => {
return this.config.video.duration;
});
if (!Number.isNaN(videoDuration)) {
return videoDuration;
}
const seekableEnd = this.readLastFinitePositiveEnd(() => {
return this.config.video.seekable;
});
if (!Number.isNaN(seekableEnd)) {
return seekableEnd;
}
return this.readLastFinitePositiveEnd(() => {
return this.config.video.buffered;
});
}
private getAudioTracks(): PlayerTrack[] {
return this.source?.kind === 'hls'
? this.hlsControls.getAudioTracks()
: [];
}
private setAudioTrack(id: number): void {
if (this.source?.kind === 'hls') {
this.hlsControls.setAudioTrack(id);
}
}
private getSubtitleTracks(): PlayerTrack[] {
if (this.source?.kind === 'hls') {
return this.hlsControls.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) {
this.nativeTextTracks.setSubtitleTrack(id);
}
}
private clearActiveSource(): void {
if (this.source?.kind === 'hls') {
this.hlsControls.clear();
} else if (this.source) {
this.nativeTextTracks.clear();
}
}
private readFinitePositive(read: () => number): number {
try {
const value = read();
return Number.isFinite(value) && value > 0 ? value : NaN;
} catch {
return NaN;
}
}
private readLastFinitePositiveEnd(read: () => TimeRanges): number {
try {
const ranges = read();
for (let index = ranges.length - 1; index >= 0; index -= 1) {
const end = ranges.end(index);
if (Number.isFinite(end) && end > 0) {
return end;
}
}
} catch {
return NaN;
}
return NaN;
}
}