feat(html-player): add feature-flagged shared controls (#1194)

* feat(html-player): bridge engine state to shared controls

* fix(html-player): avoid HLS subtitle event reentry

* fix(html-player): restore delayed HLS default subtitles

* refactor(html-player): split controls bridge collaborators

* feat(html-player): add feature-flagged shared controls

* test(html-player): cover shared-controls source ownership

* feat(html-player): pass shared-controls playback metadata

* docs(player-controls): describe HTML5 shared-controls bridge

* docs(player-controls): clarify HTML5 rollout effect

* fix(html-player): reveal diagnostics from fullscreen

* fix(html-player): defer HLS event resolution

* refactor(html-player): isolate video element session
This commit is contained in:
4gray authored and GitHub committed 2026-07-16 21:30:41 +02:00
1 parent f611ea3d7c
commit c49ea2f6a8
29 files changed
+4185 -236

No files matched your search

+14 -2
View File
@@ -147,8 +147,20 @@ Key files:
commands are cancelled. Same-session IPC replies also yield to a broadcast
snapshot received while the command was pending, preventing a successful
recording acknowledgement from being rolled back by a stale reply.
- HTML5/hls.js, Video.js, and ArtPlayer are not wired yet. Their existing skins
remain active and the web rollout token remains default-off.
- 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.
`HtmlVideoElementSession` owns native video-event lifecycle, persisted
volume, start-time/time/ended propagation, and legacy post-play caption
suppression.
`WebPlayerViewComponent.resolvedIsLive` supplies authoritative live/VOD
metadata, while a visible playback diagnostic disables both shared surface
interaction and shortcuts and exits the HTML5 shell's own fullscreen so the
diagnostic actions remain visible. The flag-off path keeps native controls
and legacy series navigation unchanged.
- Video.js and ArtPlayer are not wired yet. Their existing skins remain active,
and the web rollout token remains default-off.
- Canonical docs: `docs/architecture/player-controls-contract.md` and
`docs/architecture/embedded-mpv-native.md`
+1 -1
View File
@@ -618,7 +618,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- 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. HTML5/hls.js, Video.js, and ArtPlayer remain unwired with their existing skins. 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 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. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit the HTML5 shell's own fullscreen so retry/fallback actions remain visible. The default-off path retains native controls and legacy series navigation. Video.js and ArtPlayer remain unwired with their existing skins. Contract: `docs/architecture/player-controls-contract.md`.
**VOD/Series Detail Pages (two-state layout)**:
+87 -36
View File
@@ -7,8 +7,7 @@ Embedded MPV rendering and native-view bounds behavior remain documented in
## Current status
The shared-controls foundation from PR #1148 now has its first runtime
consumer:
The shared-controls foundation from PR #1148 now has two runtime consumers:
- the `PlayerController` contract, default state, and capability presets;
- the standalone `app-player-controls` presentation component and its
@@ -17,7 +16,8 @@ consumer:
- a default-off web rollout token;
- the component-scoped `EmbeddedMpvControlsAdapter`;
- an `EmbeddedMpvPlayerComponent` host integration for the frame-copy engine;
and
- a feature-flagged `HtmlVideoPlayerComponent` integration backed by
`WebVideoControlsAdapter` and a player-local engine bridge; and
- focused unit/component tests.
When Embedded MPV reports `engine: 'frame-copy'`, the component mounts
@@ -26,10 +26,17 @@ When Embedded MPV reports `engine: 'frame-copy'`, the component mounts
component keeps the existing compositor-safe controls dock. Exactly one of
those control systems is active at a time.
Video.js, html5+hls.js, and ArtPlayer do not consume the shared layer yet. Their
existing skins remain active, and `WEB_PLAYER_SHARED_CONTROLS_ENABLED` remains
default-off with no runtime web host consuming it. Changing that token alone
does not switch any web player UI.
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.
Video.js and ArtPlayer do not consume the shared layer yet. Their existing
skins remain active, and `WEB_PLAYER_SHARED_CONTROLS_ENABLED` remains
default-off, so the guarded HTML5 integration does not change 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
@@ -75,19 +82,21 @@ contract does not make a native video surface behave like DOM content.
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ WebVideoControlsAdapter │ │ EmbeddedMpvControlsAdapter │
│ Landed, generic, not wired │ │ Landed, component-scoped │
└──────────────────────────────┘ └──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ EmbeddedMpvPlayerComponent │
│ frame-copy: shared controls │
│ native-view: legacy dock │
└──────────────────────────────┘
│ Generic, component-scoped │ │ Component-scoped │
└──────────────┬───────────────┘ └──────────────┬───────────────┘
│ │
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ HtmlVideoPlayerComponent │ │ EmbeddedMpvPlayerComponent │
│ flag on: shared controls │ │ frame-copy: shared controls │
│ flag off: native controls │ │ native-view: legacy dock │
└──────────────────────────────┘ └──────────────────────────────┘
```
The embedded host selects controls from the reported engine before rendering
them. It never mounts the shared overlay and legacy dock together.
The HTML5 host likewise selects native or shared controls before rendering and
never attaches the web adapter while the native path is active.
## The contract
@@ -160,8 +169,8 @@ size follows the fullscreen DOM surface.
## Shared default controls
`PlayerControlsComponent` is a standalone presentation component. The
frame-copy Embedded MPV host mounts it over its DOM canvas; future web hosts can
mount the same component over their playback surfaces.
frame-copy Embedded MPV host mounts it over its DOM canvas, and the guarded
HTML5 host mounts it over `.html-video-player-shell`.
It owns only transient presentation behavior:
@@ -218,6 +227,15 @@ 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 host applies the same ownership rule while its playback diagnostic is
visible: `WebPlayerViewComponent` passes
`interactionEnabled = visiblePlaybackDiagnostic() === null`, and the HTML5
component binds that value to both `showControls` and `shortcutsEnabled`.
If that shell owns DOM fullscreen, the host exits fullscreen before hiding the
controls so the sibling diagnostic banner and its retry/fallback actions remain
visible; fullscreen owned by another element is left untouched. Retrying
playback or clearing the diagnostic restores both interaction paths.
Frame-copy recording transitions use the adapter's playback/session identity as
their `transitionKey`. Session disposal, retry, channel changes, and engine
handoff therefore clear stale recording ownership without showing a false
@@ -245,15 +263,15 @@ capability, initialization runs again for the new capability epoch. The volume
slider intentionally remains continuous: each volume `input` applies the
optimistic volume immediately.
## Web adapter (landed, not wired)
## Web adapter and HTML5 engine bridge
`WebVideoControlsAdapter` can translate an `HTMLVideoElement` into the shared
contract. It uses DOM/media events and accepts optional engine-specific track
accessors through `WebVideoControlsOptions`, so the adapter itself stays usable
in the PWA and does not import a concrete web engine.
Native media events refresh the adapter automatically. A future engine host
must call the public `refresh()` hook after engine-specific getters change
Native media events refresh the adapter automatically. An engine host must call
the public `refresh()` hook after engine-specific getters change
without a corresponding media event, including track lists, corrected duration,
or live/VOD classification. Source, readiness, progress, seeking, and playback
events that can invalidate the snapshot are observed directly.
@@ -271,20 +289,34 @@ 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`.
`web-video-controls.host.ts` contains small attachment/projection helpers for a
future host integration. No Video.js, html5+hls.js, or ArtPlayer component calls
those helpers.
`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.
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.
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.
`web-video-controls.host.ts` still contains small generic
attachment/projection helpers. Video.js and ArtPlayer do not call them yet.
The rollout symbols are:
| Symbol | Default | Current effect |
| ------------------------------------ | ------------: | ----------------------------------------------------------------------------------- |
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Documents the intended web rollout default. |
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view of the default. No runtime web player consumes it. |
A follow-up web integration must explicitly consume the token, attach the
adapter to the active video element, mount `app-player-controls`, and only then
disable the engine's existing skin.
| 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 the HTML5 host to switch atomically between native and shared controls. |
## Embedded MPV rendering constraints
@@ -335,9 +367,9 @@ renderer, bounds, and platform details.
The remaining design seams are:
1. **Web hosts** — mount the component, consume the rollout token, attach
`WebVideoControlsAdapter`, and remove an engine skin only when the shared
controls are active.
1. **Video.js and ArtPlayer** — add engine-specific adapters/bridges, consume
the rollout token, and remove each engine skin only when shared controls are
active.
2. **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.
@@ -388,5 +420,24 @@ libs/ui/playback/src/lib/embedded-mpv-player/
```
The adapter and recording helpers are component-scoped through
`EmbeddedMpvPlayerComponent`. Web host wiring, removal of web engine skins, and
`EmbeddedMpvPlayerComponent`.
The guarded HTML5 integration lives in:
```text
libs/ui/playback/src/lib/html-video-player/
├── html-video-element-session.ts
├── html-video-player-controls.bridge.ts
├── html-video-player-hls-controls.ts
├── html-video-player-native-text-tracks.ts
├── html-video-player.component.ts
└── html-video-player.component.html
```
`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. Video.js/ArtPlayer skin removal and
persistent/background player ownership have not landed.
@@ -0,0 +1,620 @@
# HTML5 Shared Controls Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebuild the useful HTML5 portion of #1152 so the built-in `<video>` player can use shared controls behind the existing default-off feature flag, with correct live/VOD metadata, track lifecycle, diagnostics gating, and cleanup.
**Architecture:** A player-local `HtmlVideoPlayerControlsBridge` owns HLS/native track projection, MPEG-TS VOD duration correction, caption preference, and adapter lifecycle. `HtmlVideoPlayerComponent` owns playback engines and chooses native versus shared chrome from `WEB_PLAYER_SHARED_CONTROLS`; `WebPlayerViewComponent` supplies authoritative playback metadata and interaction availability.
**Tech Stack:** Angular standalone components and signals, hls.js 1.6, mpegts.js, DOM media/text-track APIs, Jest/TestBed, Nx.
---
### Task 1: Add the HTML5 engine-to-controls bridge
**Files:**
- Create: `libs/ui/playback/src/lib/html-video-player/html-video-player-controls.bridge.ts`
- Create: `libs/ui/playback/src/lib/html-video-player/html-video-player-hls-controls.ts`
- Create: `libs/ui/playback/src/lib/html-video-player/html-video-player-native-text-tracks.ts`
- Create: focused `html-video-player-controls.*.spec.ts` suites and
`html-video-player-controls.spec-fixtures.ts`
- Modify: `libs/ui/playback/tsconfig.lib.json` to exclude test-only fixture
modules from the production TypeScript program
- [x] **Step 1: Write failing MPEG-TS duration tests**
Create a video fixture whose `duration`, `seekable`, and `buffered` properties
can be replaced. Cover:
```ts
expect(readState({ duration: 120, seekableEnd: 115 }).durationSeconds).toBe(
120
);
expect(
readState({ duration: Infinity, seekableEnd: 115 }).durationSeconds
).toBe(115);
expect(
readState({
duration: Infinity,
seekableEnd: NaN,
bufferedEnd: 112,
}).durationSeconds
).toBe(112);
expect(
readState({
duration: Infinity,
seekableThrows: true,
bufferedThrows: true,
}).durationSeconds
).toBeNull();
```
Assert all cases remain `isLive: false`, and that `canSeek` is false until a
seekable range exists.
- [x] **Step 2: Run the focused test and verify the red state**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns=html-video-player-controls.bridge
```
Expected: failure because `HtmlVideoPlayerControlsBridge` does not exist.
- [x] **Step 3: Add the bridge lifecycle and corrected-duration source**
Define:
```ts
export type HtmlVideoControlsSource =
| { kind: 'native' }
| { kind: 'mpegts' }
| { kind: 'hls'; hls: Hls };
export interface HtmlVideoPlayerControlsBridgeConfig {
video: HTMLVideoElement;
adapter: WebVideoControlsAdapter;
isLive: () => boolean;
showCaptions: () => boolean;
}
```
Implement an exported class with:
```ts
attach(): void;
setSource(source: HtmlVideoControlsSource): void;
refreshInputs(): void;
clearSource(): void;
destroy(): void;
```
`attach()` must be idempotent and call:
```ts
this.adapter.attach(this.video, {
isLive: this.isLive,
getDuration: () => this.readDuration(),
getAudioTracks: () => this.getAudioTracks(),
setAudioTrack: (id) => this.setAudioTrack(id),
getSubtitleTracks: () => this.getSubtitleTracks(),
setSubtitleTrack: (id) => this.setSubtitleTrack(id),
});
```
For non-live mpegts sources, `readDuration()` must return the first finite,
positive value from the video duration, last seekable end, then last buffered
end. For every other source return `NaN`, allowing the adapter to read the
native element duration.
- [x] **Step 4: Write failing HLS projection and lifecycle tests**
Use a fake HLS object with stable `on`/`off` spies and mutable:
```ts
audioTracks;
audioTrack;
subtitleTracks;
subtitleTrack;
subtitleDisplay;
```
Cover:
- audio IDs are current-list indices and selection follows `audioTrack`;
- subtitle selection requires both `subtitleDisplay` and matching index;
- labels prefer `name`, then `lang`, then `Audio N` / `Subtitle N`;
- valid audio/subtitle choices update HLS;
- subtitle `-1` disables display and selection;
- non-integer, stale, and out-of-range IDs are no-ops;
- adapter refresh occurs for `AUDIO_TRACKS_UPDATED`,
`AUDIO_TRACK_SWITCHING`, `AUDIO_TRACK_SWITCHED`,
`SUBTITLE_TRACKS_UPDATED`, `SUBTITLE_TRACKS_CLEARED`,
`SUBTITLE_TRACK_SWITCH`, and `MANIFEST_LOADING`;
- rebinding unregisters the exact old callback references before the old HLS
instance can be destroyed.
- [x] **Step 5: Implement HLS track mapping and listeners**
Use current-list indices:
```ts
return hls.audioTracks.map((track, index) => ({
id: index,
label: track.name || track.lang || `Audio ${index + 1}`,
selected: index === hls.audioTrack,
}));
```
Use the corresponding subtitle projection:
```ts
selected: hls.subtitleDisplay === true && index === hls.subtitleTrack;
```
Register one named refresh callback per bridge/source and unregister it with
the same function reference. Do not call `hls.off(event)` without a listener.
- [x] **Step 6: Write failing native text-track and preference tests**
Provide a fake `TextTrackList` implementing indexed access and
`addEventListener`/`removeEventListener`. Cover:
- only `captions` and `subtitles` tracks are projected;
- labels prefer `label`, then `language`, then `Subtitle N`;
- IDs remain stable when an earlier track is removed;
- selecting a valid ID shows it and hides other eligible tracks;
- selecting `-1` hides all eligible tracks;
- invalid/stale IDs are no-ops;
- `addtrack`, `removetrack`, and `change` refresh the adapter;
- `showCaptions = false` suppresses a default track arriving later;
- without a user override, returning the preference to true restores the
engine/default showing state that was suppressed;
- explicit selection and explicit off survive later track events and
`refreshInputs()` calls;
- source replacement clears the override and resets per-source IDs; and
- destroy removes all listeners and detaches the adapter exactly once even
when called twice.
- [x] **Step 7: Implement native tracks, caption preference, and cleanup**
Use per-source maps:
```ts
private nativeTrackIds = new WeakMap<TextTrack, number>();
private suppressedNativeModes = new WeakMap<TextTrack, TextTrackMode>();
private nextNativeTrackId = 0;
private subtitleOverride: number | null = null;
```
On preference suppression, remember a track's original mode only once and
hide any showing caption/subtitle. When preference returns to true and no
explicit override exists, restore remembered modes. Track selection sets the
override before changing modes. `clearSource()` resets all per-source maps and
the override, removes listeners, clears the active source, and refreshes the
adapter. `destroy()` calls `clearSource()` and `adapter.detach()` once.
- [x] **Step 8: Run bridge tests**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns=html-video-player-controls.bridge
```
Expected: all bridge tests pass.
- [x] **Step 9: Commit the bridge**
```bash
git add \
libs/ui/playback/src/lib/html-video-player/html-video-player-controls.bridge.ts \
libs/ui/playback/src/lib/html-video-player/html-video-player-controls.bridge.spec.ts
git commit -m "feat(html-player): bridge engine state to shared controls"
```
### Task 2: Mount shared controls in the HTML5 player
**Files:**
- Modify: `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts`
- Modify: `libs/ui/playback/src/lib/html-video-player/html-video-player.component.html`
- Create: focused `html-video-player.component.shared-controls*.spec.ts` suites
and `html-video-player.component.shared-controls.spec-fixtures.ts`
- Modify: `libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts`
- [x] **Step 1: Write failing feature-flag selection tests**
In the default-off suite assert:
```ts
expect(video.controls).toBe(true);
expect(query(By.directive(PlayerControlsComponent))).toBeNull();
expect(queryLegacySeriesControls()).not.toBeNull();
expect(adapter.attach).not.toHaveBeenCalled();
```
In a dedicated flag-on suite override:
```ts
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true }
```
and assert one shared-controls instance, `video.controls === false`, and no
legacy series-controls instance.
- [x] **Step 2: Write failing surface, interaction, and context tests**
For the shared-controls instance assert:
```ts
expect(playerControls.playerSurface()).toBe(
fixture.debugElement.query(By.css('.html-video-player-shell')).nativeElement
);
expect(playerControls.showControls()).toBe(false);
expect(playerControls.shortcutsEnabled()).toBe(false);
```
after setting `interactionEnabled = false`. Change `seriesNavigation` after
initial render and assert the adapter state/capabilities reflect the new value.
- [x] **Step 3: Verify the component tests fail**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns='html-video-player.component'
```
Expected: failures because the HTML5 component does not yet import shared
controls or expose the new inputs.
- [x] **Step 4: Add feature-flagged template ownership**
Import and provide:
```ts
(PlayerControlsComponent, WEB_PLAYER_SHARED_CONTROLS, WebVideoControlsAdapter);
```
Add:
```ts
readonly sharedControls = inject(WEB_PLAYER_SHARED_CONTROLS);
readonly controlsAdapter = inject(WebVideoControlsAdapter);
private readonly seriesNavigationSignal =
signal<SeriesPlaybackNavigation | null>(null);
@Input() isLive = true;
@Input() interactionEnabled = true;
```
Use the actual shell as the template surface:
```html
<div #playerRoot class="html-video-player-shell">
<video
#videoPlayer
id="video-player"
autoplay
[controls]="!sharedControls"
></video>
@if (sharedControls) {
<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>
```
- [x] **Step 5: Write failing source-order and mpegts metadata tests**
Cover the Angular initial order where `ngOnChanges` starts a source before
`ngOnInit` creates the bridge. Assert that the retained HLS source is bound
after initialization.
For a raw TS channel assert:
```ts
expect(mpegts.createPlayer).toHaveBeenCalledWith({
type: 'mpegts',
isLive: false,
url: expect.any(String),
});
```
when the input is VOD. Replace HLS with native playback and assert old HLS
listeners are removed before `destroy()`.
- [x] **Step 6: Integrate bridge and source lifecycle**
Make `hls` nullable and retain:
```ts
private controlsBridge: HtmlVideoPlayerControlsBridge | null = null;
private controlsSource: HtmlVideoControlsSource | null = null;
```
In `ngOnInit`, only for the enabled flag:
```ts
this.controlsAdapter.setContext({
seriesNavigation: this.seriesNavigationSignal,
});
this.controlsBridge = new HtmlVideoPlayerControlsBridge({
video: this.videoPlayer.nativeElement,
adapter: this.controlsAdapter,
isLive: () => this.isLive,
showCaptions: () => this.showCaptions,
});
this.controlsBridge.attach();
if (this.controlsSource) {
this.controlsBridge.setSource(this.controlsSource);
}
```
At the start of every source replacement:
```ts
this.controlsBridge?.clearSource();
this.controlsSource = null;
```
Then tear down old mpegts/HLS engines. After creating a new engine, assign and
bind exactly one source:
```ts
this.controlsSource = { kind: 'hls', hls: this.hls };
this.controlsBridge?.setSource(this.controlsSource);
```
Use `{ kind: 'mpegts' }` or `{ kind: 'native' }` for the other paths. Pass
`this.isLive` into `mpegts.createPlayer`.
In `ngOnChanges`, update the series signal and call `refreshInputs()` when
`isLive` or `showCaptions` changes. Keep the existing flag-off
`handlePlayOperation()` caption behavior unchanged.
- [x] **Step 7: Make destruction idempotent and ordered**
Destroy the controls bridge before destroying the HLS instance so listener
removal is observable and deterministic:
```ts
this.controlsBridge?.destroy();
this.controlsBridge = null;
this.controlsSource = null;
```
Then perform existing mpegts/HLS teardown and set the engine fields to null.
- [x] **Step 8: Run HTML5 component and bridge tests**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns='html-video-player'
```
Expected: all HTML5 suites pass in both flag states.
- [x] **Step 9: Commit the host integration**
```bash
git add libs/ui/playback/src/lib/html-video-player/
git commit -m "feat(html-player): add feature-flagged shared controls"
```
### Task 3: Supply playback metadata and diagnostic interaction gating
**Files:**
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.html`
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts`
- Create:
`libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts`
- [x] **Step 1: Extend the HTML5 test stub and write failing metadata tests**
Add stub inputs:
```ts
readonly isLive = input(true);
readonly interactionEnabled = input(true);
```
Cover:
```ts
{ isLive: false } // explicit VOD wins
{ isLive: true, contentInfo: vodInfo } // explicit live wins
{ contentInfo: vodInfo } // inferred VOD
{} // inferred live
```
Select the HTML5 player and assert the stub receives the resolved value.
- [x] **Step 2: Write failing diagnostic interaction tests**
Select HTML5, emit a playback issue, and assert:
```ts
expect(htmlPlayer.interactionEnabled()).toBe(false);
```
Call `retryPlayback()` and assert it becomes true. Also call
`handlePlaybackIssue(null)` after a new issue and assert interactions are
re-enabled. When the HTML5 player shell owns DOM fullscreen, assert the
diagnostic transition exits that shell; an unrelated fullscreen element must
remain untouched.
- [x] **Step 3: Verify web-player-view tests fail**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns=web-player-view.component
```
Expected: failures because the HTML5 inputs and public computed values do not
exist.
- [x] **Step 4: Add shared authoritative computed values**
Replace the private duplicated classification helper with:
```ts
readonly resolvedIsLive = computed(() => {
const playback = this.resolvedPlayback();
return typeof playback.isLive === 'boolean'
? playback.isLive
: !playback.contentInfo;
});
readonly playbackInteractionEnabled = computed(
() => this.visiblePlaybackDiagnostic() === null
);
```
Read `resolvedIsLive()` inside the constructor effect and use it for
`setVjsOptions`. Use it again in `retryPlayback()`.
- [x] **Step 5: Pass values to HTML5**
Add:
```html
[isLive]="resolvedIsLive()" [interactionEnabled]="playbackInteractionEnabled()"
```
to the `app-html-video-player` branch.
- [x] **Step 6: Run focused view and HTML5 tests**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand \
--testPathPatterns='web-player-view.component|html-video-player'
```
Expected: all selected suites pass.
- [x] **Step 7: Commit metadata and gating**
```bash
git add \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.html \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts
git commit -m "feat(html-player): use authoritative playback metadata"
```
### Task 4: Document and validate the HTML5 consumer
**Files:**
- Modify: `docs/architecture/player-controls-contract.md`
- Modify: `AGENTS.md`
- Modify: `CLAUDE.md`
- Add: `docs/superpowers/specs/2026-07-16-html5-shared-controls-design.md`
- Add: `docs/superpowers/plans/2026-07-16-html5-shared-controls.md`
- [x] **Step 1: Update canonical player-controls documentation**
Document:
- HTML5 is the second shared-controls consumer, behind the default-off web
feature flag;
- the player-local bridge owns HLS/native tracks and MPEG-TS duration;
- live/VOD state comes from resolved playback metadata;
- diagnostics disable shared pointer and keyboard ownership; and
- Video.js/ArtPlayer remain future migrations.
- [x] **Step 2: Mirror the ownership contract**
Update the shared-player-controls sections in `AGENTS.md` and `CLAUDE.md` with
the same behavior and key file paths. Keep the mirrored process text
consistent.
- [x] **Step 3: Run the complete validation ladder**
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache --runInBand
pnpm nx lint ui-playback --skip-nx-cache
pnpm run typecheck:ci
pnpm run i18n:check
pnpm exec prettier --check \
libs/ui/playback/src/lib/html-video-player \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.html \
libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts \
docs/architecture/player-controls-contract.md \
docs/superpowers/specs/2026-07-16-html5-shared-controls-design.md \
docs/superpowers/plans/2026-07-16-html5-shared-controls.md \
AGENTS.md CLAUDE.md
git diff --check
```
Expected: every command exits successfully.
- [x] **Step 4: Perform the test-impact pass**
Record that:
- `ui-playback` unit tests cover the flag switch, track lifecycle, metadata,
diagnostics, and cleanup;
- no new runtime behavior is enabled by default;
- broad Electron E2E is deferred to PR CI because the shared-controls flag is
compile-time default-off; and
- manual visual verification is required before enabling the flag globally,
not before merging this guarded integration.
Recorded on 2026-07-16:
- `ui-playback`: 51 suites and 492 tests passed;
- `ui-playback` lint, repository web/backend typecheck, i18n drift check,
Prettier, and `git diff --check` passed;
- no E2E suite was added because the compile-time rollout flag remains
default-off and the existing runtime workflow is unchanged; and
- visual/manual playback verification remains a prerequisite for changing the
rollout default, not for merging this guarded consumer.
- [x] **Step 5: Commit docs and final validation fixes**
```bash
git add \
docs/architecture/player-controls-contract.md \
docs/superpowers/specs/2026-07-16-html5-shared-controls-design.md \
docs/superpowers/plans/2026-07-16-html5-shared-controls.md \
AGENTS.md CLAUDE.md
git commit -m "docs(player-controls): describe HTML5 shared-controls bridge"
```
- [x] **Step 6: Review the complete branch**
Run a spec-compliance review followed by a code-quality review against
`origin/master...HEAD`. Resolve every important finding, rerun affected tests,
then request GitHub Codex and Greptile reviews after opening the fresh PR.
@@ -0,0 +1,259 @@
# HTML5 Shared Controls Design
## Status
Implemented and branch-validated as part of the
embedded-MPV/shared-controls merge plan. This rebuilds the unique HTML5
integration proposed in #1152 on top of the merged shared-controls contract and
frame-copy implementation, without merging the obsolete stacked history from
#1150/#1151. The guarded integration is pending pull-request review and merge;
the rollout default remains off.
## Goal
Use `app-player-controls` as the optional controls UI for the built-in HTML5
player while preserving its current native controls and behavior when
`WEB_PLAYER_SHARED_CONTROLS` is disabled.
## Non-goals
- Do not enable `WEB_PLAYER_SHARED_CONTROLS` by default.
- Do not change the generic `PlayerController` or
`WebVideoControlsAdapter` contracts.
- Do not infer live/VOD state from `HTMLVideoElement.duration`.
- Do not migrate Video.js or ArtPlayer in this PR.
- Do not redesign playback diagnostics or replace their existing banner.
- Do not add engine-specific knowledge to the generic shared-controls adapter.
## Why the old #1152 commit is not merged directly
The old commit correctly identified the HTML5 `<video>` element as a natural
consumer of `WebVideoControlsAdapter`, but its implementation predates the
current playback metadata, diagnostic overlay, MPEG-TS VOD handling, and
track-lifecycle requirements. In particular, it:
- classified live playback from `video.duration`;
- attached only a bare video adapter with no HLS/native track bridge;
- did not cleanly rebind engine listeners on source replacement;
- did not disable interactions while playback diagnostics own the UI; and
- used the custom-element host instead of the actual player shell as the
fullscreen and pointer surface.
The new implementation keeps the useful feature-flagged UI switch while
rebuilding its lifecycle against the current architecture.
## Architecture
### Authoritative playback classification
`WebPlayerViewComponent` exposes one `resolvedIsLive` computed value:
1. an explicit `ResolvedPortalPlayback.isLive` value wins;
2. otherwise playback with `contentInfo` is VOD; and
3. playback without `contentInfo` is live.
The same value configures Video.js and is passed to
`HtmlVideoPlayerComponent`. The HTML5 player passes it to mpegts.js and to the
shared-controls adapter. Media duration is never used to decide whether a
source is live.
### HTML5 controls bridge
Add a player-local `HtmlVideoPlayerControlsBridge`. It owns all glue between
the engine-specific source lifecycle and the engine-agnostic
`WebVideoControlsAdapter`:
```ts
type HtmlVideoControlsSource =
| { kind: 'native' }
| { kind: 'mpegts' }
| { kind: 'hls'; hls: Hls };
interface HtmlVideoPlayerControlsBridgeConfig {
video: HTMLVideoElement;
adapter: WebVideoControlsAdapter;
isLive: () => boolean;
showCaptions: () => boolean;
}
```
The bridge:
- attaches the adapter once;
- supplies authoritative live/VOD and corrected duration accessors;
- projects HLS audio and subtitle tracks into shared `PlayerTrack` values;
- projects native text tracks for native and mpegts.js sources;
- refreshes the adapter when engine track state changes;
- applies caption preference until the user makes an explicit per-source
subtitle choice;
- removes listeners before an HLS instance is destroyed; and
- supports idempotent source clearing and destruction.
The implementation keeps the bridge focused by delegating HLS projection and
caption policy to `HtmlVideoPlayerHlsControls`, and native text-track identity
and policy to `HtmlVideoPlayerNativeTextTracks`.
`HtmlVideoPlayerComponent` remains responsible for creating and destroying
native, HLS, and mpegts.js playback engines. Before replacing a source it asks
the bridge to clear the current source, then tears down the old engine. Once
the new engine exists it binds the corresponding source to the bridge.
`HtmlVideoElementSession` owns the native video-element listeners, persisted
volume, start-time/time/ended propagation, and the legacy post-play caption
behavior. It initializes lazily because initial `ngOnChanges` may start
playback before `ngOnInit` attaches the native event listeners.
Angular calls the initial `ngOnChanges` before `ngOnInit`, so the component
retains the current controls source even when playback starts before the bridge
is created. `ngOnInit` creates the bridge, attaches the adapter, and binds that
retained source.
### MPEG-TS VOD duration
Raw MPEG-TS VOD can leave `video.duration` at `Infinity`. For a non-live
mpegts.js source, the corrected duration accessor uses the first finite,
positive value from:
1. `video.duration`;
2. the last finite, positive `video.seekable.end(index)` value; and
3. the last finite, positive `video.buffered.end(index)` value.
Ranges are scanned from the end and accessor failures are ignored. If no
duration is known, the accessor returns `NaN`. The adapter still classifies the
source as VOD, but disables seeking until a positive duration and seekable
range are available.
### HLS tracks
Shared track IDs are HLS list indices because the hls.js setters accept list
indices.
Audio tracks:
- map `hls.audioTracks`;
- select the item whose index equals `hls.audioTrack`;
- label with `name`, then `lang`, then a translated-neutral numbered fallback;
- reject non-integer and out-of-range selections; and
- refresh on `AUDIO_TRACKS_UPDATED`, `AUDIO_TRACK_SWITCHING`, and
`AUDIO_TRACK_SWITCHED`.
Subtitle tracks:
- map `hls.subtitleTracks`;
- mark a track selected only when `hls.subtitleDisplay` is true and its index
equals `hls.subtitleTrack`;
- selecting `-1` sets `subtitleTrack = -1` and disables
`subtitleDisplay`;
- selecting a valid index enables `subtitleDisplay` and assigns the index;
- reject other invalid values; and
- refresh on `SUBTITLE_TRACKS_UPDATED`, `SUBTITLE_TRACKS_CLEARED`, and
`SUBTITLE_TRACK_SWITCH`.
The bridge also refreshes on `MANIFEST_LOADING`, because the installed hls.js
version clears its internal audio/subtitle groups at manifest start without
emitting an audio-track-cleared event.
### Native text tracks
Native and mpegts.js sources expose `video.textTracks`. The bridge includes
only `subtitles` and `captions` tracks and assigns stable numeric IDs through a
per-source `WeakMap<TextTrack, number>`, so removals or reordering do not change
the identity of remaining tracks.
It listens for `addtrack`, `removetrack`, and `change`. Selecting a valid ID
sets that track to `showing` and hides other caption/subtitle tracks. Selecting
`-1` hides all of them. A stale or invalid ID is a no-op.
### Caption preference and user override
Each source starts without an explicit subtitle override:
- `showCaptions = false` actively hides default tracks, including tracks added
after playback starts;
- `showCaptions = true` preserves the engine-selected/default state and does
not arbitrarily select the first track. If the bridge previously suppressed
that state while the preference was off, it restores the remembered engine
modes when the preference returns to on.
Once the user selects a subtitle track or explicitly turns subtitles off, that
choice wins over later track events and settings emissions for the rest of the
source. Replacing or clearing the source resets the override.
The feature-flag-off path keeps the current post-play caption behavior
unchanged.
### Shared-controls host and interaction ownership
When `WEB_PLAYER_SHARED_CONTROLS` is enabled,
`HtmlVideoPlayerComponent`:
- removes the native video skin;
- renders exactly one `app-player-controls`;
- passes `.html-video-player-shell` as `playerSurface`;
- forwards series-navigation events; and
- uses a reactive signal for series-navigation context.
When the flag is disabled, the native video controls and existing standalone
series-navigation controls remain unchanged and the adapter is never attached.
`WebPlayerViewComponent` derives
`playbackInteractionEnabled = visiblePlaybackDiagnostic() === null` and passes
it to the HTML5 player. The HTML5 shared-controls instance binds both
`showControls` and `shortcutsEnabled` to that input. This hides the bar,
detaches click/double-click ownership, and disables shortcuts while the
diagnostic banner is active. Retrying or clearing the diagnostic re-enables
interactions. Because the diagnostic banner is outside the HTML5 fullscreen
shell, disabling interactions also exits fullscreen when that shell is the
current fullscreen owner; unrelated fullscreen elements are not affected.
## Error handling and cleanup
- Invalid or stale track selections are ignored.
- Track/range accessors tolerate transient browser exceptions.
- Old HLS listeners are removed before `hls.destroy()`.
- `clearSource()` and `destroy()` are idempotent.
- Destroying the component removes native media listeners, destroys active
playback engines, removes text/HLS track listeners, and detaches the adapter.
## Testing
### Bridge unit coverage
- MPEG-TS duration priority, invalid ranges, and throwing range accessors;
- HLS audio/subtitle projection, selection, off state, and invalid IDs;
- HLS update/switch refresh;
- native text-track filtering, stable IDs, selection, and off state;
- native add/remove/change refresh;
- suppression of late default captions when the preference is off;
- preservation of explicit selection/off across later events and preference
changes;
- source replacement resetting the override;
- old HLS listener removal on rebind; and
- listener/adapter cleanup with idempotent destroy.
### Component integration coverage
- flag on: one shared-controls instance, no native skin or legacy series
controls;
- flag off: native skin and legacy series controls, no adapter attachment;
- the player shell is the interaction/fullscreen surface;
- the interaction input gates both shared-controls inputs;
- a diagnostic exits only the HTML5 player's own fullscreen shell;
- series-navigation context remains reactive;
- mpegts.js receives authoritative live/VOD metadata; and
- source replacement does not retain old HLS tracks/listeners.
### Web-player-view coverage
- explicit `isLive` wins;
- otherwise `contentInfo` means VOD and its absence means live;
- the resolved value reaches the HTML5 player;
- a visible diagnostic disables HTML5 shared interactions; and
- retrying or clearing the diagnostic re-enables them.
## Documentation impact
Update `docs/architecture/player-controls-contract.md`, `AGENTS.md`, and
`CLAUDE.md` to document the feature-flagged HTML5 consumer, authoritative
live/VOD metadata, engine track bridge, and diagnostic interaction gating.
The root README remains unchanged while the feature flag defaults to off.
@@ -0,0 +1,142 @@
import { HtmlVideoElementSession } from './html-video-element-session';
describe('HtmlVideoElementSession', () => {
it('owns native video events and detaches them idempotently', () => {
const video = document.createElement('video');
const playbackIssues: unknown[] = [];
const timeUpdates: Array<{ currentTime: number; duration: number }> =
[];
const playbackEnded = jest.fn();
const removeEventListener = jest.spyOn(video, 'removeEventListener');
const session = new HtmlVideoElementSession({
video,
getChannelUrl: () => 'https://example.test/video.ts',
getStartTime: () => 12,
showCaptions: () => true,
sharedControls: () => false,
emitPlaybackIssue: (issue) => playbackIssues.push(issue),
emitTimeUpdate: (value) => timeUpdates.push(value),
emitPlaybackEnded: playbackEnded,
});
Object.defineProperty(video, 'duration', {
configurable: true,
value: 90,
});
Object.defineProperty(video, 'error', {
configurable: true,
value: {
code: 4,
message: 'No compatible source was found',
},
});
session.attach();
session.attach();
video.dispatchEvent(new Event('loadedmetadata'));
expect(video.currentTime).toBe(12);
video.currentTime = 18;
video.dispatchEvent(new Event('timeupdate'));
video.dispatchEvent(new Event('error'));
video.dispatchEvent(new Event('loadeddata'));
video.dispatchEvent(new Event('playing'));
video.dispatchEvent(new Event('ended'));
expect(video.currentTime).toBe(18);
expect(timeUpdates).toEqual([{ currentTime: 18, duration: 90 }]);
expect(playbackIssues[0]).toEqual(
expect.objectContaining({
code: 'unsupported-container',
source: 'native',
sourceUrl: 'https://example.test/video.ts',
})
);
expect(playbackIssues.slice(1)).toEqual([null, null]);
expect(playbackEnded).toHaveBeenCalledTimes(1);
session.destroy();
session.destroy();
video.dispatchEvent(new Event('ended'));
expect(playbackEnded).toHaveBeenCalledTimes(1);
expect(removeEventListener).toHaveBeenCalledTimes(7);
});
it('persists volume changes from the native video element', () => {
const video = document.createElement('video');
video.volume = 0.35;
const session = createSession(video);
localStorage.removeItem('volume');
session.attach();
video.dispatchEvent(new Event('volumechange'));
expect(localStorage.getItem('volume')).toBe('0.35');
session.destroy();
});
it('suppresses legacy captions only after successful non-shared playback', async () => {
const video = document.createElement('video');
const tracks = [
{ mode: 'showing' as TextTrackMode },
{ mode: 'showing' as TextTrackMode },
];
Object.defineProperty(video, 'textTracks', {
configurable: true,
value: tracks,
});
jest.spyOn(video, 'play').mockResolvedValue(undefined);
const session = createSession(video, {
showCaptions: () => false,
sharedControls: () => false,
});
session.play();
await Promise.resolve();
expect(tracks.map((track) => track.mode)).toEqual(['hidden', 'hidden']);
});
it.each([
{ showCaptions: true, sharedControls: false },
{ showCaptions: false, sharedControls: true },
])(
'keeps captions when showCaptions=$showCaptions and sharedControls=$sharedControls',
async ({ showCaptions, sharedControls }) => {
const video = document.createElement('video');
const tracks = [{ mode: 'showing' as TextTrackMode }];
Object.defineProperty(video, 'textTracks', {
configurable: true,
value: tracks,
});
jest.spyOn(video, 'play').mockResolvedValue(undefined);
const session = createSession(video, {
showCaptions: () => showCaptions,
sharedControls: () => sharedControls,
});
session.play();
await Promise.resolve();
expect(tracks[0].mode).toBe('showing');
}
);
});
function createSession(
video: HTMLVideoElement,
overrides: Partial<
ConstructorParameters<typeof HtmlVideoElementSession>[0]
> = {}
): HtmlVideoElementSession {
return new HtmlVideoElementSession({
video,
getChannelUrl: () => undefined,
getStartTime: () => 0,
showCaptions: () => true,
sharedControls: () => false,
emitPlaybackIssue: jest.fn(),
emitTimeUpdate: jest.fn(),
emitPlaybackEnded: jest.fn(),
...overrides,
});
}
@@ -0,0 +1,128 @@
import { createDevLogger } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
export interface HtmlVideoElementSessionConfig {
video: HTMLVideoElement;
getChannelUrl: () => string | undefined;
getStartTime: () => number;
showCaptions: () => boolean;
sharedControls: () => boolean;
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitPlaybackEnded: () => void;
}
export class HtmlVideoElementSession {
private attached = false;
private readonly handleNativePlaybackError = (): void => {
const video = this.config.video;
const url = this.config.getChannelUrl() ?? video.currentSrc;
this.config.emitPlaybackIssue(
classifyNativePlaybackIssue(
video.error,
createPlaybackSourceMetadata({
url,
player: InlinePlaybackPlayer.Html5,
})
)
);
};
private readonly clearPlaybackIssue = (): void => {
this.config.emitPlaybackIssue(null);
};
private readonly handleVolumeChange = (): void => {
const currentVolume = this.config.video.volume;
debugHtmlPlayer('Volume changed to:', currentVolume);
localStorage.setItem('volume', currentVolume.toString());
};
private readonly handleLoadedMetadata = (): void => {
const startTime = this.config.getStartTime();
if (startTime > 0) {
this.config.video.currentTime = startTime;
}
};
private readonly handleTimeUpdate = (): void => {
const video = this.config.video;
this.config.emitTimeUpdate({
currentTime: video.currentTime,
duration: video.duration,
});
};
private readonly handlePlaybackEnded = (): void => {
this.config.emitPlaybackEnded();
};
private readonly listeners: ReadonlyArray<
readonly [event: string, listener: EventListener]
> = [
['volumechange', this.handleVolumeChange],
['loadedmetadata', this.handleLoadedMetadata],
['timeupdate', this.handleTimeUpdate],
['error', this.handleNativePlaybackError],
['loadeddata', this.clearPlaybackIssue],
['playing', this.clearPlaybackIssue],
['ended', this.handlePlaybackEnded],
];
constructor(private readonly config: HtmlVideoElementSessionConfig) {}
attach(): void {
if (this.attached) {
return;
}
this.attached = true;
for (const [event, listener] of this.listeners) {
this.config.video.addEventListener(event, listener);
}
}
play(): void {
const playPromise = this.config.video.play();
if (playPromise === undefined) {
return;
}
void playPromise
.then(() => {
if (
!this.config.sharedControls() &&
!this.config.showCaptions()
) {
this.disableCaptions();
}
})
.catch(() => {
// Autoplay failures are surfaced by the surrounding player UI.
});
}
disableCaptions(): void {
const tracks = this.config.video.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
tracks[index].mode = 'hidden';
}
}
destroy(): void {
if (!this.attached) {
return;
}
this.attached = false;
for (const [event, listener] of this.listeners) {
this.config.video.removeEventListener(event, listener);
}
}
}
@@ -0,0 +1,197 @@
import { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
import { HtmlVideoPlayerControlsBridge } from './html-video-player-controls.bridge';
import {
createVideo,
readMpegtsState,
} from './html-video-player-controls.spec-fixtures';
describe('HtmlVideoPlayerControlsBridge MPEG-TS duration', () => {
it('prefers a finite positive video duration', () => {
const state = readMpegtsState({
duration: 120,
seekableEnds: [115],
bufferedEnds: [112],
});
expect(state.durationSeconds).toBe(120);
expect(state.isLive).toBe(false);
expect(state.canSeek).toBe(true);
});
it('falls back to the last finite positive seekable end', () => {
const state = readMpegtsState({
duration: Infinity,
seekableEnds: [90, NaN, 115],
bufferedEnds: [112],
});
expect(state.durationSeconds).toBe(115);
expect(state.isLive).toBe(false);
expect(state.canSeek).toBe(true);
});
it('scans seekable ranges backward past invalid ends', () => {
const state = readMpegtsState({
duration: Infinity,
seekableEnds: [105, NaN, -1],
bufferedEnds: [112],
});
expect(state.durationSeconds).toBe(105);
});
it('falls back to the last finite positive buffered end', () => {
const state = readMpegtsState({
duration: Infinity,
seekableEnds: [NaN],
bufferedEnds: [80, NaN, 112],
});
expect(state.durationSeconds).toBe(112);
expect(state.isLive).toBe(false);
expect(state.canSeek).toBe(true);
});
it('returns no corrected duration when ranges throw or are invalid', () => {
const throwingState = readMpegtsState({
duration: Infinity,
seekableEnds: [115],
bufferedEnds: [112],
seekableThrows: true,
bufferedThrows: true,
});
const invalidState = readMpegtsState({
duration: Infinity,
seekableEnds: [NaN, -1, 0],
bufferedEnds: [Infinity, -1, 0],
});
expect(throwingState.durationSeconds).toBeNull();
expect(invalidState.durationSeconds).toBeNull();
});
it('keeps non-live MPEG-TS non-seekable until a seekable range exists', () => {
const state = readMpegtsState({
duration: Infinity,
bufferedEnds: [112],
});
expect(state.durationSeconds).toBe(112);
expect(state.isLive).toBe(false);
expect(state.canSeek).toBe(false);
});
it('uses only the authoritative live input for live classification', () => {
const liveState = readMpegtsState(
{
duration: 120,
seekableEnds: [115],
},
true
);
const vodState = readMpegtsState({
duration: Infinity,
seekableEnds: [115],
});
expect(liveState.isLive).toBe(true);
expect(liveState.durationSeconds).toBeNull();
expect(vodState.isLive).toBe(false);
expect(vodState.durationSeconds).toBe(115);
});
});
describe('HtmlVideoPlayerControlsBridge lifecycle', () => {
it('attaches the adapter exactly once with engine accessors', () => {
const video = createVideo({ duration: 90 });
const adapter = new WebVideoControlsAdapter();
const attachSpy = jest.spyOn(adapter, 'attach');
const bridge = new HtmlVideoPlayerControlsBridge({
video,
adapter,
isLive: () => false,
showCaptions: () => true,
});
bridge.attach();
bridge.attach();
expect(attachSpy).toHaveBeenCalledTimes(1);
expect(attachSpy).toHaveBeenCalledWith(
video,
expect.objectContaining({
isLive: expect.any(Function),
getDuration: expect.any(Function),
getAudioTracks: expect.any(Function),
setAudioTrack: expect.any(Function),
getSubtitleTracks: expect.any(Function),
setSubtitleTrack: expect.any(Function),
})
);
bridge.destroy();
});
it('lets non-MPEG-TS sources fall back to the video duration', () => {
const adapter = new WebVideoControlsAdapter();
const bridge = new HtmlVideoPlayerControlsBridge({
video: createVideo({ duration: 87, seekableEnds: [80] }),
adapter,
isLive: () => false,
showCaptions: () => true,
});
bridge.attach();
bridge.setSource({ kind: 'native' });
expect(adapter.state().durationSeconds).toBe(87);
bridge.destroy();
});
it('clears corrected source state and refreshes authoritative inputs', () => {
let isLive = false;
const adapter = new WebVideoControlsAdapter();
const bridge = new HtmlVideoPlayerControlsBridge({
video: createVideo({
duration: Infinity,
seekableEnds: [115],
}),
adapter,
isLive: () => isLive,
showCaptions: () => true,
});
bridge.attach();
bridge.setSource({ kind: 'mpegts' });
expect(adapter.state().durationSeconds).toBe(115);
expect(adapter.state().isLive).toBe(false);
isLive = true;
bridge.refreshInputs();
expect(adapter.state().isLive).toBe(true);
expect(adapter.state().durationSeconds).toBeNull();
isLive = false;
bridge.clearSource();
expect(adapter.state().isLive).toBe(false);
expect(adapter.state().durationSeconds).toBeNull();
bridge.destroy();
});
it('detaches the adapter exactly once when destroyed twice', () => {
const adapter = new WebVideoControlsAdapter();
const detachSpy = jest.spyOn(adapter, 'detach');
const bridge = new HtmlVideoPlayerControlsBridge({
video: createVideo(),
adapter,
isLive: () => false,
showCaptions: () => true,
});
bridge.attach();
detachSpy.mockClear();
bridge.destroy();
bridge.destroy();
expect(detachSpy).toHaveBeenCalledTimes(1);
});
});
@@ -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 { 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;
}
}
@@ -0,0 +1,176 @@
import Hls from 'hls.js';
import { bindHls, FakeHls } from './html-video-player-controls.spec-fixtures';
describe('HtmlVideoPlayerControlsBridge HLS caption preference', () => {
it('suppresses initial and late HLS subtitle display while disabled', () => {
const hls = new FakeHls();
hls.subtitleTracks = [{ name: 'English' }];
hls.subtitleTrack = 0;
hls.subtitleDisplay = true;
const { adapter, bridge } = bindHls(hls, false);
expect(hls.subtitleDisplay).toBe(false);
expect(adapter.state().subtitleTracks[0].selected).toBe(false);
hls.subtitleDisplay = true;
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
expect(hls.subtitleDisplay).toBe(false);
expect(adapter.state().subtitleTracks[0].selected).toBe(false);
bridge.destroy();
});
it('restores an HLS default selected while display remains disabled', () => {
const hls = new FakeHls();
const { bridge, captionPreference } = bindHls(hls, false);
hls.subtitleTracks = [{ name: 'English' }];
hls.subtitleTrack = 0;
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
expect(hls.subtitleDisplay).toBe(false);
captionPreference.value = true;
bridge.refreshInputs();
expect(hls.subtitleTrack).toBe(0);
expect(hls.subtitleDisplay).toBe(true);
bridge.destroy();
});
it('restores the retained HLS subtitle when preference returns', () => {
const hls = new FakeHls();
hls.subtitleTracks = [{ name: 'English' }, { name: 'German' }];
hls.subtitleTrack = 1;
hls.subtitleDisplay = true;
const { adapter, bridge, captionPreference } = bindHls(hls, false);
hls.subtitleTrack = -1;
captionPreference.value = true;
bridge.refreshInputs();
expect(hls.subtitleDisplay).toBe(true);
expect(hls.subtitleTrack).toBe(1);
expect(adapter.state().subtitleTracks[1].selected).toBe(true);
bridge.destroy();
});
it('keeps an explicit HLS selection through events and preference changes', () => {
const hls = new FakeHls();
hls.subtitleTracks = [{ name: 'English' }, { name: 'German' }];
const { adapter, bridge, captionPreference } = bindHls(hls);
adapter.commands.setSubtitleTrack(1);
captionPreference.value = false;
bridge.refreshInputs();
hls.subtitleDisplay = false;
hls.subtitleTrack = 0;
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
captionPreference.value = true;
bridge.refreshInputs();
expect(hls.subtitleDisplay).toBe(true);
expect(hls.subtitleTrack).toBe(1);
bridge.destroy();
});
it('keeps explicit HLS off through events and preference changes', () => {
const hls = new FakeHls();
hls.subtitleTracks = [{ name: 'English' }];
const { adapter, bridge, captionPreference } = bindHls(hls);
adapter.commands.setSubtitleTrack(-1);
hls.subtitleTrack = 0;
hls.subtitleDisplay = true;
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
captionPreference.value = false;
bridge.refreshInputs();
captionPreference.value = true;
bridge.refreshInputs();
expect(hls.subtitleTrack).toBe(-1);
expect(hls.subtitleDisplay).toBe(false);
bridge.destroy();
});
it('resets the explicit HLS override on source replacement', () => {
const firstHls = new FakeHls();
firstHls.subtitleTracks = [{ name: 'One' }, { name: 'Two' }];
const { adapter, bridge } = bindHls(firstHls);
adapter.commands.setSubtitleTrack(1);
const secondHls = new FakeHls();
secondHls.subtitleTracks = [{ name: 'Alpha' }, { name: 'Beta' }];
secondHls.subtitleTrack = 0;
secondHls.subtitleDisplay = true;
bridge.setSource({ kind: 'hls', hls: secondHls.asHls() });
expect(secondHls.subtitleTrack).toBe(0);
expect(secondHls.subtitleDisplay).toBe(true);
bridge.destroy();
});
});
describe('HtmlVideoPlayerControlsBridge HLS subtitle event reentry', () => {
it('settles explicit valid selection after one synchronous switch event', () => {
const hls = new FakeHls();
hls.subtitleTracks = [{ name: 'English' }, { name: 'German' }];
const { adapter, bridge } = bindHls(hls);
hls.assignments.length = 0;
hls.enableSynchronousSubtitleTrackSwitch();
expect(() => adapter.commands.setSubtitleTrack(1)).not.toThrow();
expect(hls.assignments).toEqual([
'subtitleDisplay:true',
'subtitleTrack:1',
]);
expect(hls.subtitleTrackSwitchEvents).toBe(1);
expect(hls.subtitleDisplay).toBe(true);
expect(hls.subtitleTrack).toBe(1);
bridge.destroy();
});
it('settles explicit off after one synchronous switch event', () => {
const hls = new FakeHls();
hls.subtitleTracks = [{ name: 'English' }];
hls.subtitleTrack = 0;
hls.subtitleDisplay = true;
const { adapter, bridge } = bindHls(hls);
hls.assignments.length = 0;
hls.enableSynchronousSubtitleTrackSwitch();
expect(() => adapter.commands.setSubtitleTrack(-1)).not.toThrow();
expect(hls.assignments).toEqual([
'subtitleTrack:-1',
'subtitleDisplay:false',
]);
expect(hls.subtitleTrackSwitchEvents).toBe(1);
expect(hls.subtitleTrack).toBe(-1);
expect(hls.subtitleDisplay).toBe(false);
bridge.destroy();
});
it('restores retained preference after one synchronous switch event', () => {
const hls = new FakeHls();
hls.subtitleTracks = [{ name: 'English' }, { name: 'German' }];
hls.subtitleTrack = 1;
hls.subtitleDisplay = true;
const { bridge, captionPreference } = bindHls(hls, false);
hls.subtitleTrack = -1;
captionPreference.value = true;
hls.assignments.length = 0;
hls.enableSynchronousSubtitleTrackSwitch();
expect(() => bridge.refreshInputs()).not.toThrow();
expect(hls.assignments).toEqual([
'subtitleDisplay:true',
'subtitleTrack:1',
]);
expect(hls.subtitleTrackSwitchEvents).toBe(1);
expect(hls.subtitleDisplay).toBe(true);
expect(hls.subtitleTrack).toBe(1);
bridge.destroy();
});
});
@@ -0,0 +1,182 @@
import Hls from 'hls.js';
import { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
import { HtmlVideoPlayerControlsBridge } from './html-video-player-controls.bridge';
import {
bindHls,
createVideo,
FakeHls,
} from './html-video-player-controls.spec-fixtures';
describe('HtmlVideoPlayerControlsBridge HLS tracks', () => {
it('projects current list indices, labels, and selected audio state', () => {
const hls = new FakeHls();
hls.audioTracks = [
{ id: 41, name: 'English' },
{ id: 3, lang: 'de' },
{ id: 99 },
];
hls.audioTrack = 1;
const { adapter, bridge } = bindHls(hls);
expect(adapter.state().audioTracks).toEqual([
{ id: 0, label: 'English', selected: false },
{ id: 1, label: 'de', selected: true },
{ id: 2, label: 'Audio 3', selected: false },
]);
bridge.destroy();
});
it('selects an HLS subtitle only when display is enabled', () => {
const hls = new FakeHls();
hls.subtitleTracks = [
{ id: 17, name: 'English CC' },
{ id: 4, lang: 'fr' },
{ id: 88 },
];
hls.subtitleTrack = 0;
hls.subtitleDisplay = false;
const { adapter, bridge } = bindHls(hls);
expect(adapter.state().subtitleTracks).toEqual([
{ id: 0, label: 'English CC', selected: false },
{ id: 1, label: 'fr', selected: false },
{ id: 2, label: 'Subtitle 3', selected: false },
]);
hls.subtitleDisplay = true;
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
expect(adapter.state().subtitleTracks[0].selected).toBe(true);
bridge.destroy();
});
it('accepts only valid current HLS audio indices', () => {
const hls = new FakeHls();
hls.audioTracks = [{ name: 'One' }, { name: 'Two' }, { name: 'Three' }];
hls.audioTrack = 0;
const { adapter, bridge } = bindHls(hls);
hls.assignments.length = 0;
adapter.commands.setAudioTrack(2);
expect(hls.audioTrack).toBe(2);
expect(hls.assignments).toEqual(['audioTrack:2']);
hls.audioTrack = 0;
hls.assignments.length = 0;
adapter.commands.setAudioTrack(-1);
adapter.commands.setAudioTrack(1.5);
adapter.commands.setAudioTrack(8);
adapter.commands.setAudioTrack(NaN);
hls.audioTracks = [{ name: 'One' }];
adapter.commands.setAudioTrack(2);
expect(hls.audioTrack).toBe(0);
expect(hls.assignments).toEqual([]);
bridge.destroy();
});
it('enables valid HLS subtitles before selection and supports explicit off', () => {
const hls = new FakeHls();
hls.subtitleTracks = [{ name: 'One' }, { name: 'Two' }];
const { adapter, bridge } = bindHls(hls);
hls.assignments.length = 0;
adapter.commands.setSubtitleTrack(1);
expect(hls.assignments).toEqual([
'subtitleDisplay:true',
'subtitleTrack:1',
]);
expect(hls.subtitleDisplay).toBe(true);
expect(hls.subtitleTrack).toBe(1);
hls.assignments.length = 0;
adapter.commands.setSubtitleTrack(-1);
expect(hls.assignments).toEqual([
'subtitleTrack:-1',
'subtitleDisplay:false',
]);
expect(hls.subtitleTrack).toBe(-1);
expect(hls.subtitleDisplay).toBe(false);
bridge.destroy();
});
it('ignores invalid and stale HLS subtitle indices', () => {
const hls = new FakeHls();
hls.subtitleTracks = [{ name: 'One' }, { name: 'Two' }];
hls.subtitleTrack = 0;
hls.subtitleDisplay = true;
const { adapter, bridge } = bindHls(hls);
hls.assignments.length = 0;
adapter.commands.setSubtitleTrack(-2);
adapter.commands.setSubtitleTrack(0.5);
adapter.commands.setSubtitleTrack(4);
adapter.commands.setSubtitleTrack(NaN);
hls.subtitleTracks = [{ name: 'One' }];
adapter.commands.setSubtitleTrack(1);
expect(hls.subtitleTrack).toBe(0);
expect(hls.subtitleDisplay).toBe(true);
expect(hls.assignments).toEqual([]);
bridge.destroy();
});
});
describe('HtmlVideoPlayerControlsBridge HLS listener lifecycle', () => {
const refreshEvents = [
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,
];
it('refreshes from every relevant HLS event with one callback reference', () => {
const hls = new FakeHls();
const adapter = new WebVideoControlsAdapter();
const refreshSpy = jest.spyOn(adapter, 'refresh');
const bridge = new HtmlVideoPlayerControlsBridge({
video: createVideo(),
adapter,
isLive: () => false,
showCaptions: () => true,
});
bridge.attach();
bridge.setSource({ kind: 'hls', hls: hls.asHls() });
const registrations = hls.on.mock.calls;
expect(registrations.map(([event]) => event)).toEqual(refreshEvents);
expect(
new Set(registrations.map(([, listener]) => listener)).size
).toBe(1);
refreshSpy.mockClear();
for (const event of refreshEvents) {
hls.emit(event);
}
expect(refreshSpy).toHaveBeenCalledTimes(refreshEvents.length);
bridge.destroy();
});
it('removes exact old HLS listeners before rebinding a source', () => {
const firstHls = new FakeHls();
const secondHls = new FakeHls();
const { bridge } = bindHls(firstHls);
const firstRegistrations = [...firstHls.on.mock.calls];
bridge.setSource({ kind: 'hls', hls: secondHls.asHls() });
expect(firstHls.off).toHaveBeenCalledTimes(refreshEvents.length);
for (const [event, listener] of firstRegistrations) {
expect(firstHls.off).toHaveBeenCalledWith(event, listener);
}
expect(firstHls.off.mock.calls.every((call) => call.length === 2)).toBe(
true
);
expect(secondHls.on.mock.calls.map(([event]) => event)).toEqual(
refreshEvents
);
bridge.destroy();
});
});
@@ -0,0 +1,144 @@
import {
bindNativeTracks,
createTextTrack,
} from './html-video-player-controls.spec-fixtures';
describe('HtmlVideoPlayerControlsBridge native caption preference', () => {
it('suppresses initial and late default captions while disabled', () => {
const initial = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const { adapter, bridge, textTracks } = bindNativeTracks(
[initial],
false
);
const late = createTextTrack({
kind: 'subtitles',
mode: 'showing',
});
expect(initial.mode).toBe('hidden');
textTracks.add(late);
expect(late.mode).toBe('hidden');
expect(
adapter.state().subtitleTracks.some((track) => track.selected)
).toBe(false);
bridge.destroy();
});
it('restores suppressed engine/default modes when preference returns', () => {
const initial = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const untouched = createTextTrack({
kind: 'subtitles',
mode: 'disabled',
});
const { bridge, captionPreference, textTracks } = bindNativeTracks(
[initial, untouched],
false
);
const late = createTextTrack({
kind: 'subtitles',
mode: 'showing',
});
textTracks.add(late);
captionPreference.value = true;
bridge.refreshInputs();
expect(initial.mode).toBe('showing');
expect(late.mode).toBe('showing');
expect(untouched.mode).toBe('disabled');
bridge.destroy();
});
it('keeps an explicit selection through events and preference changes', () => {
const selected = createTextTrack({
kind: 'captions',
mode: 'hidden',
});
const other = createTextTrack({
kind: 'subtitles',
mode: 'showing',
});
const { adapter, bridge, captionPreference, textTracks } =
bindNativeTracks([selected, other]);
adapter.commands.setSubtitleTrack(0);
captionPreference.value = false;
bridge.refreshInputs();
selected.mode = 'hidden';
other.mode = 'showing';
textTracks.emit('change');
captionPreference.value = true;
bridge.refreshInputs();
expect(selected.mode).toBe('showing');
expect(other.mode).toBe('hidden');
bridge.destroy();
});
it('keeps explicit off through events and preference changes', () => {
const first = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const second = createTextTrack({
kind: 'subtitles',
mode: 'hidden',
});
const { adapter, bridge, captionPreference, textTracks } =
bindNativeTracks([first, second]);
adapter.commands.setSubtitleTrack(-1);
first.mode = 'showing';
second.mode = 'showing';
textTracks.emit('change');
captionPreference.value = false;
bridge.refreshInputs();
captionPreference.value = true;
bridge.refreshInputs();
expect(first.mode).toBe('hidden');
expect(second.mode).toBe('hidden');
bridge.destroy();
});
it('resets native IDs and the explicit override on source replacement', () => {
const first = createTextTrack({ kind: 'captions' });
const second = createTextTrack({ kind: 'subtitles' });
const { adapter, bridge, textTracks } = bindNativeTracks([
first,
second,
]);
adapter.commands.setSubtitleTrack(1);
const replacement = createTextTrack({
kind: 'subtitles',
mode: 'showing',
});
textTracks.replaceSilently([replacement]);
bridge.setSource({ kind: 'native' });
expect(adapter.state().subtitleTracks).toEqual([
{ id: 0, label: 'Subtitle 1', selected: true },
]);
expect(replacement.mode).toBe('showing');
bridge.destroy();
});
it('cleans native listeners and detaches once on double destroy', () => {
const { adapter, bridge, textTracks } = bindNativeTracks([]);
const detachSpy = jest.spyOn(adapter, 'detach');
bridge.destroy();
bridge.destroy();
expect(textTracks.removeEventListener).toHaveBeenCalledTimes(3);
expect(detachSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,162 @@
import {
bindNativeTracks,
createTextTrack,
} from './html-video-player-controls.spec-fixtures';
describe('HtmlVideoPlayerControlsBridge native text tracks', () => {
it('projects only captions/subtitles with stable IDs and fallback labels', () => {
const first = createTextTrack({
kind: 'captions',
label: 'English CC',
mode: 'showing',
});
const ignored = createTextTrack({
kind: 'metadata',
label: 'Cue metadata',
mode: 'showing',
});
const second = createTextTrack({
kind: 'subtitles',
language: 'de',
});
const third = createTextTrack({ kind: 'captions' });
const { adapter, bridge, textTracks } = bindNativeTracks([
first,
ignored,
second,
third,
]);
expect(adapter.state().subtitleTracks).toEqual([
{ id: 0, label: 'English CC', selected: true },
{ id: 1, label: 'de', selected: false },
{ id: 2, label: 'Subtitle 3', selected: false },
]);
textTracks.remove(first);
expect(adapter.state().subtitleTracks).toEqual([
{ id: 1, label: 'de', selected: false },
{ id: 2, label: 'Subtitle 3', selected: false },
]);
bridge.destroy();
});
it('uses native text tracks for MPEG-TS sources too', () => {
const subtitle = createTextTrack({
kind: 'subtitles',
label: 'English',
mode: 'showing',
});
const { adapter, bridge } = bindNativeTracks(
[subtitle],
true,
'mpegts'
);
expect(adapter.state().subtitleTracks).toEqual([
{ id: 0, label: 'English', selected: true },
]);
bridge.destroy();
});
it('selects a valid native track and hides other eligible tracks', () => {
const first = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const second = createTextTrack({
kind: 'subtitles',
mode: 'hidden',
});
const ignored = createTextTrack({
kind: 'metadata',
mode: 'showing',
});
const { adapter, bridge } = bindNativeTracks([first, second, ignored]);
adapter.commands.setSubtitleTrack(1);
expect(first.mode).toBe('hidden');
expect(second.mode).toBe('showing');
expect(ignored.mode).toBe('showing');
bridge.destroy();
});
it('supports explicit native subtitle off', () => {
const first = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const second = createTextTrack({
kind: 'subtitles',
mode: 'showing',
});
const { adapter, bridge } = bindNativeTracks([first, second]);
adapter.commands.setSubtitleTrack(-1);
expect(first.mode).toBe('hidden');
expect(second.mode).toBe('hidden');
bridge.destroy();
});
it('ignores invalid, non-integer, and stale native track IDs', () => {
const removed = createTextTrack({
kind: 'captions',
mode: 'hidden',
});
const remaining = createTextTrack({
kind: 'subtitles',
mode: 'showing',
});
const { adapter, bridge, textTracks } = bindNativeTracks([
removed,
remaining,
]);
textTracks.remove(removed);
adapter.commands.setSubtitleTrack(0);
adapter.commands.setSubtitleTrack(0.5);
adapter.commands.setSubtitleTrack(-2);
adapter.commands.setSubtitleTrack(8);
adapter.commands.setSubtitleTrack(NaN);
expect(remaining.mode).toBe('showing');
bridge.destroy();
});
it('refreshes on addtrack, removetrack, and change', () => {
const { adapter, bridge, textTracks } = bindNativeTracks([]);
const refreshSpy = jest.spyOn(adapter, 'refresh');
textTracks.emit('addtrack');
textTracks.emit('removetrack');
textTracks.emit('change');
expect(refreshSpy).toHaveBeenCalledTimes(3);
bridge.destroy();
});
it('removes exact native listener references on rebind and destroy', () => {
const { bridge, textTracks } = bindNativeTracks([]);
const firstRegistrations = [...textTracks.addEventListener.mock.calls];
bridge.setSource({ kind: 'mpegts' });
expect(textTracks.removeEventListener).toHaveBeenCalledTimes(3);
for (const [event, listener] of firstRegistrations) {
expect(textTracks.removeEventListener).toHaveBeenCalledWith(
event,
listener
);
}
expect(
textTracks.removeEventListener.mock.calls.every(
(call) => call.length === 2
)
).toBe(true);
bridge.destroy();
expect(textTracks.removeEventListener).toHaveBeenCalledTimes(6);
});
});
@@ -0,0 +1,312 @@
import { jest } from '@jest/globals';
import Hls from 'hls.js';
import { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
import { HtmlVideoPlayerControlsBridge } from './html-video-player-controls.bridge';
export interface VideoFixtureOptions {
duration?: number;
seekableEnds?: number[];
bufferedEnds?: number[];
seekableThrows?: boolean;
bufferedThrows?: boolean;
}
interface FakeHlsTrack {
id?: number;
name?: string;
lang?: string;
}
type FakeHlsListener = (...args: unknown[]) => void;
export class FakeHls {
audioTracks: FakeHlsTrack[] = [];
subtitleTracks: FakeHlsTrack[] = [];
readonly assignments: string[] = [];
subtitleTrackSwitchEvents = 0;
private readonly listeners = new Map<string, Set<FakeHlsListener>>();
private emitSubtitleTrackSwitchOnAssignment = false;
private selectedAudioTrack = -1;
private selectedSubtitleTrack = -1;
private displaySubtitles = false;
readonly on = jest.fn((event: string, listener: FakeHlsListener): void => {
const eventListeners =
this.listeners.get(event) ?? new Set<FakeHlsListener>();
eventListeners.add(listener);
this.listeners.set(event, eventListeners);
});
readonly off = jest.fn((event: string, listener: FakeHlsListener): void => {
this.listeners.get(event)?.delete(listener);
});
get audioTrack(): number {
return this.selectedAudioTrack;
}
set audioTrack(value: number) {
this.assignments.push(`audioTrack:${value}`);
this.selectedAudioTrack = value;
}
get subtitleTrack(): number {
return this.selectedSubtitleTrack;
}
set subtitleTrack(value: number) {
this.assignments.push(`subtitleTrack:${value}`);
this.selectedSubtitleTrack = value;
if (this.emitSubtitleTrackSwitchOnAssignment) {
this.subtitleTrackSwitchEvents += 1;
if (this.subtitleTrackSwitchEvents > 10) {
throw new Error('recursive HLS subtitle track assignment');
}
this.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
}
}
get subtitleDisplay(): boolean {
return this.displaySubtitles;
}
set subtitleDisplay(value: boolean) {
this.assignments.push(`subtitleDisplay:${value}`);
this.displaySubtitles = value;
}
emit(event: string): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(event, {});
}
}
enableSynchronousSubtitleTrackSwitch(): void {
this.subtitleTrackSwitchEvents = 0;
this.emitSubtitleTrackSwitchOnAssignment = true;
}
asHls(): Hls {
return this as unknown as Hls;
}
}
export interface FakeTextTrackOptions {
kind?: string;
label?: string;
language?: string;
mode?: TextTrackMode;
}
export function createTextTrack(options: FakeTextTrackOptions = {}): TextTrack {
return {
kind: options.kind ?? 'subtitles',
label: options.label ?? '',
language: options.language ?? '',
mode: options.mode ?? 'hidden',
} as unknown as TextTrack;
}
export class FakeTextTrackList {
private readonly listeners = new Map<
string,
Set<EventListenerOrEventListenerObject>
>();
private tracks: TextTrack[];
private indexedLength = 0;
readonly addEventListener = jest.fn(
(event: string, listener: EventListenerOrEventListenerObject): void => {
const eventListeners =
this.listeners.get(event) ??
new Set<EventListenerOrEventListenerObject>();
eventListeners.add(listener);
this.listeners.set(event, eventListeners);
}
);
readonly removeEventListener = jest.fn(
(event: string, listener: EventListenerOrEventListenerObject): void => {
this.listeners.get(event)?.delete(listener);
}
);
constructor(tracks: TextTrack[] = []) {
this.tracks = [...tracks];
this.syncIndexedTracks();
}
get length(): number {
return this.tracks.length;
}
getTrackById(): TextTrack | null {
return null;
}
add(track: TextTrack): void {
this.tracks.push(track);
this.syncIndexedTracks();
this.emit('addtrack');
}
remove(track: TextTrack): void {
const index = this.tracks.indexOf(track);
if (index < 0) {
return;
}
this.tracks.splice(index, 1);
this.syncIndexedTracks();
this.emit('removetrack');
}
replaceSilently(tracks: TextTrack[]): void {
this.tracks = [...tracks];
this.syncIndexedTracks();
}
emit(eventName: 'addtrack' | 'removetrack' | 'change'): void {
const event = new Event(eventName);
for (const listener of this.listeners.get(eventName) ?? []) {
if (typeof listener === 'function') {
listener.call(this, event);
} else {
listener.handleEvent(event);
}
}
}
asTextTrackList(): TextTrackList {
return this as unknown as TextTrackList;
}
private syncIndexedTracks(): void {
for (let index = 0; index < this.indexedLength; index += 1) {
Reflect.deleteProperty(this, index);
}
this.tracks.forEach((track, index) => {
Object.defineProperty(this, index, {
configurable: true,
enumerable: true,
value: track,
});
});
this.indexedLength = this.tracks.length;
}
}
function createTimeRanges(ends: number[], throwsWhenRead: boolean): TimeRanges {
return {
length: ends.length,
start: (index: number) => {
if (throwsWhenRead) {
throw new Error('range start unavailable');
}
return index === 0 ? 0 : (ends[index - 1] ?? 0);
},
end: (index: number) => {
if (throwsWhenRead) {
throw new Error('range end unavailable');
}
return ends[index] ?? NaN;
},
};
}
export function createVideo(
options: VideoFixtureOptions = {}
): HTMLVideoElement {
const video = document.createElement('video');
Object.defineProperties(video, {
duration: {
configurable: true,
value: options.duration ?? NaN,
},
seekable: {
configurable: true,
value: createTimeRanges(
options.seekableEnds ?? [],
options.seekableThrows ?? false
),
},
buffered: {
configurable: true,
value: createTimeRanges(
options.bufferedEnds ?? [],
options.bufferedThrows ?? false
),
},
readyState: {
configurable: true,
value: 4,
},
networkState: {
configurable: true,
value: 1,
},
paused: {
configurable: true,
value: false,
},
});
return video;
}
export function readMpegtsState(options: VideoFixtureOptions, isLive = false) {
const adapter = new WebVideoControlsAdapter();
const bridge = new HtmlVideoPlayerControlsBridge({
video: createVideo(options),
adapter,
isLive: () => isLive,
showCaptions: () => true,
});
bridge.attach();
bridge.setSource({ kind: 'mpegts' });
const state = adapter.state();
bridge.destroy();
return state;
}
export function bindHls(fakeHls: FakeHls, showCaptions = true) {
const adapter = new WebVideoControlsAdapter();
const captionPreference = { value: showCaptions };
const bridge = new HtmlVideoPlayerControlsBridge({
video: createVideo({ duration: 90, seekableEnds: [80] }),
adapter,
isLive: () => false,
showCaptions: () => captionPreference.value,
});
bridge.attach();
bridge.setSource({ kind: 'hls', hls: fakeHls.asHls() });
return { adapter, bridge, captionPreference };
}
export function bindNativeTracks(
tracks: TextTrack[],
showCaptions = true,
kind: 'native' | 'mpegts' = 'native'
) {
const textTracks = new FakeTextTrackList(tracks);
const video = createVideo({ duration: 90, seekableEnds: [80] });
Object.defineProperty(video, 'textTracks', {
configurable: true,
value: textTracks.asTextTrackList(),
});
const captionPreference = { value: showCaptions };
const adapter = new WebVideoControlsAdapter();
const bridge = new HtmlVideoPlayerControlsBridge({
video,
adapter,
isLive: () => false,
showCaptions: () => captionPreference.value,
});
bridge.attach();
bridge.setSource({ kind });
return {
adapter,
bridge,
captionPreference,
textTracks,
};
}
@@ -0,0 +1,175 @@
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;
}
@@ -0,0 +1,173 @@
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';
}
}
}
@@ -1,14 +1,25 @@
<div class="html-video-player-shell">
<div #playerRoot class="html-video-player-shell">
<video
#videoPlayer
id="video-player"
autoplay="true"
controls="true"
autoplay
[controls]="!sharedControls"
></video>
<app-series-playback-navigation-controls
[navigation]="seriesNavigation"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
@if (sharedControls) {
<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>
@@ -0,0 +1,247 @@
import { By } from '@angular/platform-browser';
import {
MockHls,
TEST_CHANNEL,
cleanupSharedControlsTests,
configureSharedControlsTests,
hlsInstances,
lifecycle,
mpegTsCreatePlayer,
mpegTsInstances,
mpegTsIsSupported,
observeBridgeSourceBinding,
readHtmlPlayerInternals,
renderSharedControls,
type SharedControlsFixture,
} from './html-video-player.component.shared-controls.spec-fixtures';
describe('HtmlVideoPlayerComponent shared controls sources', () => {
let HtmlVideoPlayerComponent: typeof import('./html-video-player.component').HtmlVideoPlayerComponent;
const fixtures: SharedControlsFixture[] = [];
beforeAll(async () => {
({ HtmlVideoPlayerComponent } =
await import('./html-video-player.component'));
});
beforeEach(async () => {
await configureSharedControlsTests(HtmlVideoPlayerComponent);
});
afterEach(() => {
cleanupSharedControlsTests(fixtures);
});
it('retains an HLS source created before bridge initialization', () => {
const { adapter } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: TEST_CHANNEL,
isLive: false,
showCaptions: true,
}
);
expect(hlsInstances).toHaveLength(1);
expect(adapter.capabilities().audioTracks).toBe(true);
expect(adapter.state().audioTracks).toEqual([
{ id: 0, label: 'English', selected: true },
{ id: 1, label: 'Deutsch', selected: false },
]);
});
it('binds HLS controls after media attach and before source loading', () => {
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
lifecycle.length = 0;
component.playChannel(TEST_CHANNEL);
const attachIndex = lifecycle.indexOf('hls:attachMedia');
const bindIndex = lifecycle.indexOf(
`on:${MockHls.Events.AUDIO_TRACKS_UPDATED}`
);
const loadIndex = lifecycle.indexOf('hls:loadSource');
expect(attachIndex).toBeGreaterThanOrEqual(0);
expect(bindIndex).toBeGreaterThanOrEqual(0);
expect(loadIndex).toBeGreaterThanOrEqual(0);
expect(attachIndex).toBeLessThan(bindIndex);
expect(bindIndex).toBeLessThan(loadIndex);
});
it.each([false, true])(
'passes authoritative isLive=%s to raw MPEG-TS playback',
(isLive) => {
mpegTsIsSupported.mockReturnValue(true);
renderSharedControls(HtmlVideoPlayerComponent, fixtures, {
channel: {
...TEST_CHANNEL,
url: 'https://example.test/raw.ts',
},
isLive,
});
expect(mpegTsCreatePlayer).toHaveBeenCalledWith({
type: 'mpegts',
isLive,
url: 'https://example.test/raw.ts',
});
}
);
it('owns one MPEG-TS source between media attachment and loading', () => {
mpegTsIsSupported.mockReturnValue(true);
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{ isLive: false }
);
const setSource = observeBridgeSourceBinding(component);
lifecycle.length = 0;
component.playChannel({
...TEST_CHANNEL,
url: 'https://example.test/raw.ts',
});
const internals = readHtmlPlayerInternals(component);
expect(setSource).toHaveBeenCalledTimes(1);
expect(setSource).toHaveBeenCalledWith({ kind: 'mpegts' });
expect(internals.controlsSource).toEqual({ kind: 'mpegts' });
expect(internals.hls).toBeNull();
expect(hlsInstances).toHaveLength(0);
expect(mpegTsInstances).toHaveLength(1);
expect(internals.mpegtsPlayer).toBe(mpegTsInstances[0]);
expect(mpegTsInstances[0].attachMediaElement).toHaveBeenCalledTimes(1);
expect(mpegTsInstances[0].load).toHaveBeenCalledTimes(1);
expect(lifecycle.indexOf('mpegts:attachMedia')).toBeLessThan(
lifecycle.indexOf('bridge:mpegts')
);
expect(lifecycle.indexOf('bridge:mpegts')).toBeLessThan(
lifecycle.indexOf('mpegts:load')
);
});
it('owns one native source before loading native media', () => {
const { component, fixture } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
const setSource = observeBridgeSourceBinding(component);
const video = fixture.debugElement.query(By.css('video'))
.nativeElement as HTMLVideoElement;
const load = jest
.spyOn(video, 'load')
.mockImplementation(() => lifecycle.push('native:load'));
lifecycle.length = 0;
component.playChannel({
...TEST_CHANNEL,
url: 'https://example.test/movie.mp4',
});
const internals = readHtmlPlayerInternals(component);
expect(setSource).toHaveBeenCalledTimes(1);
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
expect(internals.controlsSource).toEqual({ kind: 'native' });
expect(internals.hls).toBeNull();
expect(internals.mpegtsPlayer).toBeNull();
expect(hlsInstances).toHaveLength(0);
expect(mpegTsInstances).toHaveLength(0);
expect(load).toHaveBeenCalledTimes(1);
expect(lifecycle.indexOf('bridge:native')).toBeLessThan(
lifecycle.indexOf('native:load')
);
});
it('refreshes live and caption inputs through authoritative closures', () => {
const { fixture, adapter } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: TEST_CHANNEL,
isLive: true,
showCaptions: false,
}
);
const hls = hlsInstances[0];
expect(adapter.state().isLive).toBe(true);
expect(hls.subtitleDisplay).toBe(false);
fixture.componentRef.setInput('isLive', false);
fixture.componentRef.setInput('showCaptions', true);
fixture.detectChanges();
expect(adapter.state().isLive).toBe(false);
expect(hls.subtitleDisplay).toBe(true);
});
it('clears old HLS listeners and tracks before destroying the engine', () => {
const { component, adapter } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: TEST_CHANNEL,
isLive: false,
showCaptions: true,
}
);
const hls = hlsInstances[0];
const setSource = observeBridgeSourceBinding(component);
lifecycle.length = 0;
component.playChannel({
...TEST_CHANNEL,
url: 'https://example.test/movie.mp4',
});
const destroyIndex = lifecycle.indexOf('hls:destroy');
expect(destroyIndex).toBeGreaterThan(0);
expect(
lifecycle
.slice(0, destroyIndex)
.filter((event) => event.startsWith('off:'))
).toHaveLength(7);
const internals = readHtmlPlayerInternals(component);
expect(setSource).toHaveBeenCalledTimes(1);
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
expect(internals.controlsSource).toEqual({ kind: 'native' });
expect(internals.hls).toBeNull();
expect(internals.mpegtsPlayer).toBeNull();
expect(hlsInstances).toHaveLength(1);
expect(mpegTsInstances).toHaveLength(0);
expect(hls.off).toHaveBeenCalledTimes(7);
expect(adapter.state().audioTracks).toEqual([]);
expect(adapter.state().subtitleTracks).toEqual([]);
});
it('destroys the bridge before HLS and detaches the adapter once', () => {
const { component, adapter } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: TEST_CHANNEL,
showCaptions: true,
}
);
const originalDetach = adapter.detach.bind(adapter);
const detach = jest.spyOn(adapter, 'detach').mockImplementation(() => {
lifecycle.push('adapter:detach');
originalDetach();
});
lifecycle.length = 0;
component.ngOnDestroy();
component.ngOnDestroy();
expect(detach).toHaveBeenCalledTimes(1);
expect(lifecycle.indexOf('adapter:detach')).toBeLessThan(
lifecycle.indexOf('hls:destroy')
);
});
});
@@ -0,0 +1,250 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import type { Channel } from '@iptvnator/shared/interfaces';
import {
PlayerControlsComponent,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import type { HtmlVideoPlayerComponent as HtmlVideoPlayerComponentInstance } from './html-video-player.component';
type HtmlVideoPlayerComponentType =
typeof import('./html-video-player.component').HtmlVideoPlayerComponent;
type HlsListener = (event: string, data: unknown) => void;
export type SharedControlsFixture =
ComponentFixture<HtmlVideoPlayerComponentInstance>;
export interface ControlsSource {
kind: 'native' | 'mpegts' | 'hls';
}
export interface HtmlPlayerInternals {
controlsBridge: {
setSource(source: ControlsSource): void;
} | null;
controlsSource: ControlsSource | null;
hls: MockHls | null;
mpegtsPlayer: MockMpegTsPlayer | null;
}
export interface RenderOptions {
channel?: Channel;
interactionEnabled?: boolean;
isLive?: boolean;
seriesNavigation?: SeriesPlaybackNavigation | null;
showCaptions?: boolean;
}
export const lifecycle: string[] = [];
export const hlsInstances: MockHls[] = [];
export const mpegTsInstances: MockMpegTsPlayer[] = [];
export class MockHls {
static readonly 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 readonly isSupported = jest.fn(() => true);
readonly audioTracks = [{ name: 'English' }, { name: 'Deutsch' }];
readonly subtitleTracks = [{ name: 'English CC' }];
audioTrack = 0;
subtitleTrack = 0;
subtitleDisplay = true;
private readonly listeners = new Map<string, Set<HlsListener>>();
readonly on = jest.fn((event: string, listener: HlsListener) => {
lifecycle.push(`on:${event}`);
const listeners = this.listeners.get(event) ?? new Set<HlsListener>();
listeners.add(listener);
this.listeners.set(event, listeners);
});
readonly off = jest.fn((event: string, listener: HlsListener) => {
lifecycle.push(`off:${event}`);
this.listeners.get(event)?.delete(listener);
});
readonly attachMedia = jest.fn(() => lifecycle.push('hls:attachMedia'));
readonly loadSource = jest.fn(() => {
lifecycle.push('hls:loadSource');
this.emit(MockHls.Events.MANIFEST_LOADING);
});
readonly destroy = jest.fn(() => lifecycle.push('hls:destroy'));
constructor() {
hlsInstances.push(this);
}
emit(event: string, data: unknown = {}): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(event, data);
}
}
}
export class MockMpegTsPlayer {
readonly attachMediaElement = jest.fn(() =>
lifecycle.push('mpegts:attachMedia')
);
readonly on = jest.fn();
readonly load = jest.fn(() => lifecycle.push('mpegts:load'));
readonly pause = jest.fn();
readonly unload = jest.fn();
readonly detachMediaElement = jest.fn();
readonly destroy = jest.fn();
constructor() {
mpegTsInstances.push(this);
}
}
export const mpegTsCreatePlayer = jest.fn(() => new MockMpegTsPlayer());
export const mpegTsIsSupported = jest.fn(() => false);
jest.unstable_mockModule('hls.js', () => ({ default: MockHls }));
jest.unstable_mockModule('mpegts.js', () => ({
default: {
Events: { ERROR: 'error' },
createPlayer: mpegTsCreatePlayer,
isSupported: mpegTsIsSupported,
},
}));
export const TEST_CHANNEL: Channel = {
id: 'channel-1',
url: 'https://example.test/live/playlist.m3u8',
name: 'Test channel',
group: { title: 'Test' },
http: { origin: '', referrer: '', 'user-agent': '' },
radio: 'false',
tvg: { id: '', logo: '', name: '', rec: '', url: '' },
};
export async function configureSharedControlsTests(
HtmlVideoPlayerComponent: HtmlVideoPlayerComponentType
): Promise<void> {
lifecycle.length = 0;
hlsInstances.length = 0;
mpegTsInstances.length = 0;
MockHls.isSupported.mockReset().mockReturnValue(true);
mpegTsIsSupported.mockReset().mockReturnValue(false);
mpegTsCreatePlayer
.mockReset()
.mockImplementation(() => new MockMpegTsPlayer());
Object.defineProperty(window, 'electron', {
configurable: true,
value: { setUserAgent: jest.fn().mockResolvedValue(true) },
});
jest.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
jest.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(
() => undefined
);
await TestBed.configureTestingModule({
imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()],
providers: [
{
provide: DataService,
useValue: {
sendIpcEvent: jest.fn().mockResolvedValue(undefined),
},
},
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true },
],
}).compileComponents();
}
export function cleanupSharedControlsTests(
fixtures: SharedControlsFixture[]
): void {
for (const fixture of fixtures.splice(0)) {
fixture.destroy();
}
delete (window as unknown as { electron?: unknown }).electron;
jest.restoreAllMocks();
}
export function renderSharedControls(
HtmlVideoPlayerComponent: HtmlVideoPlayerComponentType,
fixtures: SharedControlsFixture[],
options: RenderOptions = {}
) {
const fixture = createFixture(HtmlVideoPlayerComponent, fixtures);
if (options.channel) {
fixture.componentRef.setInput('channel', options.channel);
}
fixture.componentRef.setInput(
'interactionEnabled',
options.interactionEnabled ?? true
);
fixture.componentRef.setInput('isLive', options.isLive ?? true);
fixture.componentRef.setInput(
'seriesNavigation',
options.seriesNavigation ?? null
);
fixture.componentRef.setInput(
'showCaptions',
options.showCaptions ?? false
);
fixture.detectChanges();
return renderedFixture(fixture);
}
export function renderSharedControlsDefaults(
HtmlVideoPlayerComponent: HtmlVideoPlayerComponentType,
fixtures: SharedControlsFixture[]
) {
const fixture = createFixture(HtmlVideoPlayerComponent, fixtures);
fixture.detectChanges();
return renderedFixture(fixture);
}
export function readHtmlPlayerInternals(
component: HtmlVideoPlayerComponentInstance
): HtmlPlayerInternals {
return component as unknown as HtmlPlayerInternals;
}
export function observeBridgeSourceBinding(
component: HtmlVideoPlayerComponentInstance
) {
const bridge = readHtmlPlayerInternals(component).controlsBridge;
if (!bridge) {
throw new Error('Expected initialized HTML player controls bridge');
}
const setSource = bridge.setSource.bind(bridge);
return jest.spyOn(bridge, 'setSource').mockImplementation((source) => {
lifecycle.push(`bridge:${source.kind}`);
setSource(source);
});
}
function createFixture(
HtmlVideoPlayerComponent: HtmlVideoPlayerComponentType,
fixtures: SharedControlsFixture[]
): SharedControlsFixture {
const fixture = TestBed.createComponent(HtmlVideoPlayerComponent);
fixtures.push(fixture);
return fixture;
}
function renderedFixture(fixture: SharedControlsFixture) {
return {
fixture,
component: fixture.componentInstance,
adapter: fixture.debugElement.injector.get(WebVideoControlsAdapter),
controls: fixture.debugElement.query(
By.directive(PlayerControlsComponent)
)?.componentInstance as PlayerControlsComponent | undefined,
};
}
@@ -0,0 +1,219 @@
import { By } from '@angular/platform-browser';
import {
PlayerControlsComponent,
WebVideoControlsAdapter,
} from '../player-controls';
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
import type { HtmlVideoPlayerComponent as HtmlVideoPlayerComponentInstance } from './html-video-player.component';
import {
cleanupSharedControlsTests,
configureSharedControlsTests,
renderSharedControls,
renderSharedControlsDefaults,
type SharedControlsFixture,
} from './html-video-player.component.shared-controls.spec-fixtures';
describe('HtmlVideoPlayerComponent shared controls host', () => {
let HtmlVideoPlayerComponent: typeof import('./html-video-player.component').HtmlVideoPlayerComponent;
const fixtures: SharedControlsFixture[] = [];
beforeAll(async () => {
({ HtmlVideoPlayerComponent } =
await import('./html-video-player.component'));
});
beforeEach(async () => {
await configureSharedControlsTests(HtmlVideoPlayerComponent);
});
afterEach(() => {
cleanupSharedControlsTests(fixtures);
});
it('uses declared shared-control defaults without input overrides', () => {
const { component, controls } = renderSharedControlsDefaults(
HtmlVideoPlayerComponent,
fixtures
);
expect(component.isLive()).toBe(true);
expect(component.interactionEnabled()).toBe(true);
expect(component.showCaptions()).toBe(false);
expect(controls?.showControls()).toBe(true);
expect(controls?.shortcutsEnabled()).toBe(true);
});
it('scopes one adapter and context initialization to each component', () => {
const setContext = jest.spyOn(
WebVideoControlsAdapter.prototype,
'setContext'
);
const first = renderSharedControls(HtmlVideoPlayerComponent, fixtures);
const second = renderSharedControls(HtmlVideoPlayerComponent, fixtures);
expect(first.adapter).not.toBe(second.adapter);
expect(setContext).toHaveBeenCalledTimes(2);
first.fixture.detectChanges();
second.fixture.detectChanges();
expect(setContext).toHaveBeenCalledTimes(2);
});
it('uses one shared controls instance on the actual player shell', () => {
const { fixture, controls } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
seriesNavigation: {
canPrevious: true,
canNext: true,
autoplayEnabled: true,
},
}
);
const shell = fixture.debugElement.query(
By.css('.html-video-player-shell')
).nativeElement;
const video = fixture.debugElement.query(By.css('video'))
.nativeElement as HTMLVideoElement;
expect(video.controls).toBe(false);
expect(
fixture.debugElement.queryAll(By.directive(PlayerControlsComponent))
).toHaveLength(1);
expect(
fixture.debugElement.query(
By.directive(SeriesPlaybackNavigationControlsComponent)
)
).toBeNull();
expect(controls?.playerSurface()).toBe(shell);
});
it('gates the shared surface and shortcuts with interaction availability', () => {
const { fixture, controls } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{ interactionEnabled: false }
);
expect(controls?.showControls()).toBe(false);
expect(controls?.shortcutsEnabled()).toBe(false);
fixture.componentRef.setInput('interactionEnabled', true);
fixture.detectChanges();
expect(controls?.showControls()).toBe(true);
expect(controls?.shortcutsEnabled()).toBe(true);
});
it('exits only its own fullscreen shell when interactions become unavailable', () => {
const { fixture } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
const shell = fixture.debugElement.query(
By.css('.html-video-player-shell')
).nativeElement as HTMLElement;
const unrelatedSurface = document.createElement('div');
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
document,
'fullscreenElement'
);
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
);
let fullscreenElement: Element | null = unrelatedSurface;
const exitFullscreen = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
try {
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).not.toHaveBeenCalled();
fixture.componentRef.setInput('interactionEnabled', true);
fixture.detectChanges();
fullscreenElement = shell;
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
} finally {
restoreDocumentProperty(
'fullscreenElement',
fullscreenElementDescriptor
);
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
}
});
it('keeps series context reactive and forwards navigation outputs', () => {
const setContext = jest.spyOn(
WebVideoControlsAdapter.prototype,
'setContext'
);
const { fixture, component, adapter, controls } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{ isLive: false }
);
const previous = jest.fn();
const next = jest.fn();
component.previousEpisodeRequested.subscribe(previous);
component.nextEpisodeRequested.subscribe(next);
expect(adapter.capabilities().seriesNavigation).toBe(false);
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: true,
canNext: false,
autoplayEnabled: true,
});
fixture.detectChanges();
expect(adapter.capabilities().seriesNavigation).toBe(true);
expect(adapter.state().canPreviousEpisode).toBe(true);
expect(adapter.state().canNextEpisode).toBe(false);
controls?.previousEpisodeRequested.emit();
controls?.nextEpisodeRequested.emit();
expect(previous).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledTimes(1);
expect(setContext).toHaveBeenCalledTimes(1);
});
it('does not run legacy post-play caption suppression', async () => {
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
const disableCaptions = jest.spyOn(
component as HtmlVideoPlayerComponentInstance,
'disableCaptions'
);
component.handlePlayOperation();
await Promise.resolve();
expect(disableCaptions).not.toHaveBeenCalled();
});
});
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];
}
@@ -4,11 +4,17 @@ import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import {
PlayerControlsComponent,
WebVideoControlsAdapter,
} from '../player-controls';
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
import { HtmlVideoPlayerComponent } from './html-video-player.component';
describe('HtmlVideoPlayerComponent', () => {
let component: HtmlVideoPlayerComponent;
let fixture: ComponentFixture<HtmlVideoPlayerComponent>;
let adapterAttach: jest.SpiedFunction<WebVideoControlsAdapter['attach']>;
const electronApi = {
setUserAgent: jest.fn().mockResolvedValue(true),
};
@@ -49,6 +55,7 @@ describe('HtmlVideoPlayerComponent', () => {
}));
beforeEach(() => {
adapterAttach = jest.spyOn(WebVideoControlsAdapter.prototype, 'attach');
Object.defineProperty(window, 'electron', {
configurable: true,
value: electronApi,
@@ -62,34 +69,61 @@ describe('HtmlVideoPlayerComponent', () => {
afterEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
jest.restoreAllMocks();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('keeps native and legacy controls when shared controls are disabled', () => {
const video = component.videoPlayer.nativeElement;
expect(video.controls).toBe(true);
expect(
fixture.debugElement.query(By.directive(PlayerControlsComponent))
).toBeNull();
expect(
fixture.debugElement.query(
By.directive(SeriesPlaybackNavigationControlsComponent)
)
).not.toBeNull();
expect(adapterAttach).not.toHaveBeenCalled();
});
it('keeps legacy post-play caption suppression when shared controls are disabled', async () => {
const video = component.videoPlayer.nativeElement;
const tracks = [{ mode: 'showing' as TextTrackMode }];
Object.defineProperty(video, 'textTracks', {
configurable: true,
value: tracks,
});
jest.spyOn(video, 'play').mockResolvedValue(undefined);
fixture.componentRef.setInput('showCaptions', false);
fixture.detectChanges();
component.handlePlayOperation();
await Promise.resolve();
expect(tracks[0].mode).toBe('hidden');
});
it('detaches volume/metadata/timeupdate listeners on destroy (no leak)', () => {
const el = component.videoPlayer.nativeElement;
const removeSpy = jest.spyOn(el, 'removeEventListener');
const handlers = component as unknown as {
handleVolumeChange: EventListener;
handleLoadedMetadata: EventListener;
handleTimeUpdate: EventListener;
};
fixture.destroy();
expect(removeSpy).toHaveBeenCalledWith(
'volumechange',
handlers.handleVolumeChange
expect.any(Function)
);
expect(removeSpy).toHaveBeenCalledWith(
'loadedmetadata',
handlers.handleLoadedMetadata
expect.any(Function)
);
expect(removeSpy).toHaveBeenCalledWith(
'timeupdate',
handlers.handleTimeUpdate
expect.any(Function)
);
});
@@ -181,9 +215,7 @@ describe('HtmlVideoPlayerComponent', () => {
const sources = Array.from(video.querySelectorAll('source'));
const [source] = sources;
expect(sources).toHaveLength(1);
expect(source?.src).toBe(
'https://stream.example/series/s01e02.mp4'
);
expect(source?.src).toBe('https://stream.example/series/s01e02.mp4');
expect(source?.type).toBe('video/mp4');
expect(loadSpy).toHaveBeenCalledTimes(2);
expect(playSpy).toHaveBeenCalledTimes(2);
@@ -3,30 +3,41 @@ import {
ElementRef,
EventEmitter,
inject,
input,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
viewChild,
ViewChild,
signal,
} from '@angular/core';
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
import mpegts from 'mpegts.js';
import { DataService } from '@iptvnator/services';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
classifyHlsPlaybackIssue,
classifyMpegTsPlaybackIssue,
classifyNativePlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import {
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 { HtmlVideoElementSession } from './html-video-element-session';
import {
HtmlVideoPlayerControlsBridge,
type HtmlVideoControlsSource,
} from './html-video-player-controls.bridge';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
@@ -37,7 +48,11 @@ const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
selector: 'app-html-video-player',
templateUrl: './html-video-player.component.html',
styleUrls: ['./html-video-player.component.scss'],
imports: [SeriesPlaybackNavigationControlsComponent],
imports: [
PlayerControlsComponent,
SeriesPlaybackNavigationControlsComponent,
],
providers: [WebVideoControlsAdapter],
standalone: true,
})
export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
@@ -46,6 +61,9 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
@Input() volume = 1;
@Input() startTime = 0;
@Input() seriesNavigation: SeriesPlaybackNavigation | null = null;
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
@@ -55,90 +73,43 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
private readonly dataService = inject(DataService);
readonly sharedControls = inject(WEB_PLAYER_SHARED_CONTROLS);
readonly controlsAdapter = inject(WebVideoControlsAdapter);
private readonly seriesNavigationSignal =
signal<SeriesPlaybackNavigation | null>(null);
/** Video player DOM element */
readonly playerRoot = viewChild<ElementRef<HTMLElement>>('playerRoot');
@ViewChild('videoPlayer', { static: true })
videoPlayer!: ElementRef<HTMLVideoElement>;
/** HLS object */
hls!: Hls;
hls: Hls | null = null;
/** mpegts.js player for raw MPEG-TS streams */
private mpegtsPlayer: mpegts.Player | null = null;
/** Captions/subtitles indicator */
@Input() showCaptions!: boolean;
private readonly handleNativePlaybackError = () => {
const metadata = this.createSourceMetadata(
this.channel?.url ?? this.videoPlayer.nativeElement.currentSrc
);
this.playbackIssue.emit(
classifyNativePlaybackIssue(
this.videoPlayer.nativeElement.error,
metadata
)
);
};
private readonly clearPlaybackIssue = () => {
this.playbackIssue.emit(null);
};
private readonly handleVolumeChange = (): void => {
this.onVolumeChange();
};
private readonly handleLoadedMetadata = (): void => {
if (this.startTime > 0) {
this.videoPlayer.nativeElement.currentTime = this.startTime;
}
};
private readonly handleTimeUpdate = (): void => {
this.timeUpdate.emit({
currentTime: this.videoPlayer.nativeElement.currentTime,
duration: this.videoPlayer.nativeElement.duration,
});
};
private readonly handlePlaybackEnded = (): void => {
this.playbackEnded.emit();
};
private controlsSource: HtmlVideoControlsSource | null = null;
private controlsBridge: HtmlVideoPlayerControlsBridge | null = null;
private videoSession: HtmlVideoElementSession | null = null;
ngOnInit() {
this.videoPlayer.nativeElement.addEventListener(
'volumechange',
this.handleVolumeChange
);
this.videoPlayer.nativeElement.addEventListener(
'loadedmetadata',
this.handleLoadedMetadata
);
this.videoPlayer.nativeElement.addEventListener(
'timeupdate',
this.handleTimeUpdate
);
this.videoPlayer.nativeElement.addEventListener(
'error',
this.handleNativePlaybackError
);
this.videoPlayer.nativeElement.addEventListener(
'loadeddata',
this.clearPlaybackIssue
);
this.videoPlayer.nativeElement.addEventListener(
'playing',
this.clearPlaybackIssue
);
this.videoPlayer.nativeElement.addEventListener(
'ended',
this.handlePlaybackEnded
);
if (this.sharedControls) {
this.seriesNavigationSignal.set(this.seriesNavigation);
this.controlsAdapter.setContext({
seriesNavigation: this.seriesNavigationSignal,
});
this.controlsBridge = new HtmlVideoPlayerControlsBridge({
video: this.videoPlayer.nativeElement,
adapter: this.controlsAdapter,
isLive: () => this.isLive(),
showCaptions: () => this.showCaptions(),
});
this.controlsBridge.attach();
if (this.controlsSource) {
this.controlsBridge.setSource(this.controlsSource);
}
}
this.getVideoSession().attach();
}
/**
@@ -146,9 +117,18 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
* @param changes component changes
*/
ngOnChanges(changes: SimpleChanges): void {
if (changes['seriesNavigation']) {
this.seriesNavigationSignal.set(this.seriesNavigation);
}
if (changes['channel'] && changes['channel'].currentValue) {
this.playChannel(changes['channel'].currentValue);
}
if (changes['isLive'] || changes['showCaptions']) {
this.controlsBridge?.refreshInputs();
}
if (changes['interactionEnabled']?.currentValue === false) {
this.exitOwnedFullscreen();
}
if (changes['volume']?.currentValue !== undefined) {
debugHtmlPlayer(
'Setting HTML5 player volume to:',
@@ -159,19 +139,37 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
}
private exitOwnedFullscreen(): void {
if (
!this.sharedControls ||
document.fullscreenElement !== this.playerRoot()?.nativeElement ||
typeof document.exitFullscreen !== 'function'
) {
return;
}
try {
void Promise.resolve(document.exitFullscreen()).catch(
(error: unknown) => {
debugHtmlPlayer(
'Failed to exit HTML5 player fullscreen:',
error
);
}
);
} catch (error: unknown) {
debugHtmlPlayer('Failed to exit HTML5 player fullscreen:', error);
}
}
/**
* Starts to play the given channel
* @param channel given channel object
*/
playChannel(channel: Channel): 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.clearControlsSource();
this.destroyMpegtsPlayer();
this.destroyHls();
this.clearNativeVideoSources(this.videoPlayer.nativeElement);
if (channel.url) {
this.playbackIssue.emit(null);
@@ -199,12 +197,13 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
);
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
isLive: true,
isLive: this.isLive(),
url: url,
});
this.mpegtsPlayer.attachMediaElement(
this.videoPlayer.nativeElement
);
this.bindControlsSource({ kind: 'mpegts' });
this.mpegtsPlayer.on(
mpegts.Events.ERROR,
(type: string, details: string, info: unknown): void => {
@@ -229,15 +228,17 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
Hls.isSupported()
) {
debugHtmlPlayer('Switching channel to:', channel.name, url);
this.hls = new Hls();
this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
const hls = new Hls();
this.hls = hls;
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
this.handleHlsManifestParsed(url, data);
});
this.hls.on(Hls.Events.ERROR, (_, data) => {
hls.on(Hls.Events.ERROR, (_, data) => {
this.handleHlsError(url, data);
});
this.hls.attachMedia(this.videoPlayer.nativeElement);
this.hls.loadSource(url);
hls.attachMedia(this.videoPlayer.nativeElement);
this.bindControlsSource({ kind: 'hls', hls });
hls.loadSource(url);
this.handlePlayOperation();
} else {
debugHtmlPlayer('Using native video player');
@@ -266,40 +267,50 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
source.src = url;
source.type = type;
element.appendChild(source);
this.bindControlsSource({ kind: 'native' });
element.load();
}
private bindControlsSource(source: HtmlVideoControlsSource): void {
this.controlsSource = source;
this.controlsBridge?.setSource(source);
}
private clearControlsSource(): void {
this.controlsBridge?.clearSource();
this.controlsSource = null;
}
private destroyMpegtsPlayer(): void {
const player = this.mpegtsPlayer;
this.mpegtsPlayer = null;
if (!player) {
return;
}
player.pause();
player.unload();
player.detachMediaElement();
player.destroy();
}
private destroyHls(): void {
const hls = this.hls;
this.hls = null;
hls?.destroy();
}
/**
* Disables text based captions based on the global settings
*/
disableCaptions(): void {
for (
let i = 0;
i < this.videoPlayer.nativeElement.textTracks.length;
i++
) {
this.videoPlayer.nativeElement.textTracks[i].mode = 'hidden';
}
this.getVideoSession().disableCaptions();
}
/**
* Handles promise based play operation
*/
handlePlayOperation(): void {
const playPromise = this.videoPlayer.nativeElement.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
// Automatic playback started!
if (!this.showCaptions) {
this.disableCaptions();
}
})
.catch(() => {
// Do nothing
});
}
this.getVideoSession().play();
}
private handleHlsManifestParsed(
@@ -356,56 +367,30 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
});
}
/**
* Save volume when user changes it
*/
onVolumeChange(): void {
const currentVolume = this.videoPlayer.nativeElement.volume;
debugHtmlPlayer('Volume changed to:', currentVolume);
localStorage.setItem('volume', currentVolume.toString());
}
/**
* Destroy hls instance on component destroy and clean up event listener
*/
ngOnDestroy(): void {
this.videoPlayer.nativeElement.removeEventListener(
'volumechange',
this.handleVolumeChange
);
this.videoPlayer.nativeElement.removeEventListener(
'loadedmetadata',
this.handleLoadedMetadata
);
this.videoPlayer.nativeElement.removeEventListener(
'timeupdate',
this.handleTimeUpdate
);
this.videoPlayer.nativeElement.removeEventListener(
'error',
this.handleNativePlaybackError
);
this.videoPlayer.nativeElement.removeEventListener(
'loadeddata',
this.clearPlaybackIssue
);
this.videoPlayer.nativeElement.removeEventListener(
'playing',
this.clearPlaybackIssue
);
this.videoPlayer.nativeElement.removeEventListener(
'ended',
this.handlePlaybackEnded
);
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.controlsBridge?.destroy();
this.controlsBridge = null;
this.controlsSource = null;
this.videoSession?.destroy();
this.videoSession = null;
this.destroyMpegtsPlayer();
this.destroyHls();
}
private getVideoSession(): HtmlVideoElementSession {
this.videoSession ??= new HtmlVideoElementSession({
video: this.videoPlayer.nativeElement,
getChannelUrl: () => this.channel?.url,
getStartTime: () => this.startTime,
showCaptions: () => this.showCaptions(),
sharedControls: () => this.sharedControls,
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
emitPlaybackEnded: () => this.playbackEnded.emit(),
});
return this.videoSession;
}
}
@@ -4,9 +4,10 @@ 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).
*
* DEFAULT OFF. #1148 adds no runtime consumer, so changing this constant alone
* has no effect until a follow-up engine host reads the injectable
* {@link WEB_PLAYER_SHARED_CONTROLS} token and performs the actual UI switch.
* DEFAULT OFF. The built-in HTML5 player consumes the injectable
* {@link WEB_PLAYER_SHARED_CONTROLS} token and switches atomically between its
* native chrome and shared controls. Video.js and ArtPlayer do not consume the
* token yet.
*/
export const WEB_PLAYER_SHARED_CONTROLS_ENABLED = false;
@@ -20,6 +20,8 @@
[channel]="$any(channel)"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
@@ -0,0 +1,276 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { Component, input, output } from '@angular/core';
import {
ComponentFixture,
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
type ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.util';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({ selector: 'app-vjs-player', template: '' })
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly volume = input(1);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({ selector: 'app-html-video-player', template: '' })
class StubHtmlVideoPlayerComponent {
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 }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({ selector: 'app-art-player', template: '' })
class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly volume = input(1);
readonly showCaptions = input(false);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({ selector: 'app-embedded-mpv-player', template: '' })
class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly recordingFolder = input('');
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
describe('WebPlayerViewComponent shared HTML5 controls metadata', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
let component: WebPlayerViewComponentInstance;
beforeAll(async () => {
({ WebPlayerViewComponent } =
await import('./web-player-view.component'));
});
beforeEach(async () => {
await TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Playthrough,
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
providers: [
{
provide: StorageMap,
useValue: {
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsManagedExternalPlayers: false },
},
],
})
.overrideComponent(WebPlayerViewComponent, {
set: {
imports: [
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
ClipboardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
TranslateModule,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WebPlayerViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
'https://example.com/default.ts'
);
fixture.componentRef.setInput('title', 'Default stream');
});
afterEach(() => {
fixture.destroy();
});
it.each([
['an explicit VOD value', { isLive: false }, false],
[
'an explicit live value with VOD content metadata',
{ isLive: true, contentInfo: createVodContentInfo() },
true,
],
[
'VOD content metadata without an explicit value',
{ contentInfo: createVodContentInfo() },
false,
],
['missing content metadata and explicit value', {}, true],
])('resolves %s', (_label, metadata, expected) => {
setPlayback(metadata);
expect(component.resolvedIsLive()).toBe(expected);
});
it('passes the resolved live value to the HTML5 player', async () => {
const htmlPlayer = await renderHtmlPlayer({ isLive: false });
expect(htmlPlayer.isLive()).toBe(false);
});
it('disables HTML5 surface interaction while a diagnostic is visible', async () => {
const htmlPlayer = await renderHtmlPlayer();
component.handlePlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(component.playbackInteractionEnabled()).toBe(false);
expect(htmlPlayer.interactionEnabled()).toBe(false);
});
it('re-enables HTML5 interaction after retrying or clearing the issue', async () => {
const htmlPlayer = await renderHtmlPlayer();
component.handlePlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(htmlPlayer.interactionEnabled()).toBe(false);
component.retryPlayback();
fixture.detectChanges();
expect(component.playbackInteractionEnabled()).toBe(true);
expect(htmlPlayer.interactionEnabled()).toBe(true);
component.handlePlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(htmlPlayer.interactionEnabled()).toBe(false);
component.handlePlaybackIssue(null);
fixture.detectChanges();
expect(component.playbackInteractionEnabled()).toBe(true);
expect(htmlPlayer.interactionEnabled()).toBe(true);
});
it.each([
['inferred VOD', { contentInfo: createVodContentInfo() }, false],
[
'explicit live VOD content',
{ isLive: true, contentInfo: createVodContentInfo() },
true,
],
])(
'uses the resolved value for Video.js effect and retry: %s',
(_label, metadata, expected) => {
const streamUrl = 'https://example.com/video.ts';
const setVjsOptions = jest.spyOn(component, 'setVjsOptions');
setPlayback(metadata, streamUrl);
fixture.detectChanges();
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
setVjsOptions.mockClear();
component.retryPlayback();
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
}
);
function setPlayback(
metadata: Partial<ResolvedPortalPlayback>,
streamUrl = 'https://example.com/playback.ts'
): void {
fixture.componentRef.setInput('playback', {
streamUrl,
title: 'Playback',
...metadata,
});
}
async function renderHtmlPlayer(
metadata: Partial<ResolvedPortalPlayback> = {}
): Promise<StubHtmlVideoPlayerComponent> {
fixture.componentRef.setInput(
'playerOverride',
VideoPlayer.Html5Player
);
setPlayback(metadata);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
return fixture.debugElement.query(
By.directive(StubHtmlVideoPlayerComponent)
).componentInstance as StubHtmlVideoPlayerComponent;
}
});
function createVodContentInfo() {
return {
playlistId: 'playlist-1',
contentXtreamId: 123,
contentType: 'vod' as const,
};
}
function createNetworkDiagnostic(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.MpegTs,
sourceUrl: 'https://example.com/playback.ts',
container: 'ts',
mimeType: 'video/mp2t',
player: 'html5',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: false,
};
}
@@ -53,6 +53,8 @@ class StubHtmlVideoPlayerComponent {
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 }>();
@@ -102,6 +102,9 @@ export class WebPlayerViewComponent {
? null
: this.playbackDiagnostic()
);
readonly playbackInteractionEnabled = computed(
() => this.visiblePlaybackDiagnostic() === null
);
readonly canShowExternalFallbackActions = computed(
() =>
this.runtime.supportsManagedExternalPlayers &&
@@ -125,6 +128,12 @@ export class WebPlayerViewComponent {
startTime: this.startTime(),
};
});
readonly resolvedIsLive = computed(() => {
const playback = this.resolvedPlayback();
return typeof playback.isLive === 'boolean'
? playback.isLive
: !playback.contentInfo;
});
readonly selectedPlayer = computed(
() =>
this.playerOverride() ??
@@ -143,10 +152,7 @@ export class WebPlayerViewComponent {
const playback = this.resolvedPlayback();
this.playbackDiagnostic.set(null);
this.setChannel(playback);
this.setVjsOptions(
playback.streamUrl,
this.isLivePlayback(playback)
);
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
});
}
@@ -233,7 +239,7 @@ export class WebPlayerViewComponent {
this.playbackDiagnostic.set(null);
this.reloadToken.update((value) => value + 1);
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, this.isLivePlayback(playback));
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
}
getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
@@ -331,14 +337,6 @@ export class WebPlayerViewComponent {
}
}
private isLivePlayback(playback: ResolvedPortalPlayback): boolean {
if (typeof playback.isLive === 'boolean') {
return playback.isLive;
}
return !playback.contentInfo;
}
private getHeaderValue(
headers: ResolvedPortalPlayback['headers'] | undefined,
name: string
+5 -1
View File
@@ -8,5 +8,9 @@
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec-fixtures.ts"
]
}
-1
View File
@@ -120,7 +120,6 @@ export const maxLinesBaseline = [
'libs/ui/components/src/lib/season-container/season-container.component.ts',
'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts',
'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts',
'libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts',
'libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts',
'libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts',
'libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts',