feat(videojs): add feature-flagged shared controls (#1195)

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

Rebuild the Video.js shared-controls integration on the current player lifecycle with Tech rebinds, source-scoped tracks, reset ordering, volume preservation, diagnostics gating, and default-off compatibility.

Credits and supersedes the stacked implementation proposed in #1153.

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

* fix(videojs): harden MPEG-TS reset lifecycle

---------

Co-authored-by: Lars Emig <lars.emig@pickware.de>
This commit is contained in:
4grayandLars Emig authored and GitHub committed 2026-07-16 23:08:18 +02:00
1 parent c49ea2f6a8
commit 4e572c60ca
31 files changed
+4516 -883

No files matched your search

+13 -2
View File
@@ -159,8 +159,19 @@ Key files:
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.
- Video.js is the third guarded consumer. `VjsPlayerComponent` provides a
component-scoped `WebVideoControlsAdapter`; its bridge binds the current Tech
video, rebinds after `playerreset`, exposes source-stable audio/subtitle IDs,
preserves caption preference and explicit subtitle-off state, and reads
duration from Video.js. Reset-driven raw MPEG-TS changes pause first,
coalesce to the latest desired source, preserve actual volume across
Video.js's reset, and restart when authoritative live/VOD metadata changes.
The flag-on path disables native controls, Video.js
click/double-click/hotkey actions, and spatial navigation;
diagnostic gating and owned-fullscreen exit match HTML5. The flag-off path
keeps the existing Video.js skin and legacy series navigation unchanged.
- ArtPlayer is not wired yet. Its existing skin remains active, and the web
rollout token remains default-off.
- 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. 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`.
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and a default-off web rollout token. Embedded MPV frame-copy consumes it through `EmbeddedMpvControlsAdapter`; the host selects exactly one UI, so native-view retains its compositor-safe dock. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its local bridge owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. With the flag on, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit the active HTML5 or Video.js shell's own fullscreen so retry/fallback actions remain visible. Default-off paths retain native/Video.js controls and legacy series navigation. ArtPlayer remains unwired with its existing skin. Contract: `docs/architecture/player-controls-contract.md`.
**VOD/Series Detail Pages (two-state layout)**:
+107 -32
View File
@@ -7,7 +7,7 @@ Embedded MPV rendering and native-view bounds behavior remain documented in
## Current status
The shared-controls foundation from PR #1148 now has two runtime consumers:
The shared-controls foundation from PR #1148 now has three runtime consumers:
- the `PlayerController` contract, default state, and capability presets;
- the standalone `app-player-controls` presentation component and its
@@ -17,7 +17,9 @@ The shared-controls foundation from PR #1148 now has two runtime consumers:
- the component-scoped `EmbeddedMpvControlsAdapter`;
- an `EmbeddedMpvPlayerComponent` host integration for the frame-copy engine;
- a feature-flagged `HtmlVideoPlayerComponent` integration backed by
`WebVideoControlsAdapter` and a player-local engine bridge; and
`WebVideoControlsAdapter` and a player-local engine bridge;
- a feature-flagged `VjsPlayerComponent` integration backed by a
component-scoped `WebVideoControlsAdapter` and Video.js bridge; and
- focused unit/component tests.
When Embedded MPV reports `engine: 'frame-copy'`, the component mounts
@@ -33,10 +35,15 @@ 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.
Video.js consumes the same token and shared presentation atomically. Its bridge
binds the adapter to the current Video.js Tech `<video>` and rebinds after
`playerreset`, while focused collaborators expose Video.js audio/text tracks
and manage raw MPEG-TS playback. With the flag disabled, Video.js keeps its
existing skin and legacy series navigation.
ArtPlayer does not consume the shared layer yet. Its existing skin remains
active, and `WEB_PLAYER_SHARED_CONTROLS_ENABLED` remains default-off, so neither
guarded web integration changes normal runtime behavior.
This rollout is intentionally engine-selective: frame-copy can use normal DOM
layering, while the native platform view cannot. The integration also includes
@@ -87,16 +94,18 @@ contract does not make a native video surface behave like DOM content.
│ │
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ HtmlVideoPlayerComponent │ │ EmbeddedMpvPlayerComponent │
│ flag on: shared controls │ │ frame-copy: shared controls │
│ flag off: native controls │ │ native-view: legacy dock │
└──────────────────────────────┘ └──────────────────────────────┘
│ Flag-guarded web hosts │ │ EmbeddedMpvPlayerComponent │
│ HTML5 + Video.js │ │ frame-copy: shared controls │
│ flag on: shared controls │ │ native-view: legacy dock │
│ flag off: existing controls │ └──────────────────────────────┘
└──────────────────────────────┘
```
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 HTML5 and Video.js hosts likewise select their existing or shared controls
before rendering and never attach the web adapter while the flag-off path is
active.
## The contract
@@ -170,7 +179,8 @@ size follows the fullscreen DOM surface.
`PlayerControlsComponent` is a standalone presentation component. The
frame-copy Embedded MPV host mounts it over its DOM canvas, and the guarded
HTML5 host mounts it over `.html-video-player-shell`.
HTML5 and Video.js hosts mount it over `.html-video-player-shell` and
`.vjs-player-shell`, respectively.
It owns only transient presentation behavior:
@@ -227,14 +237,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.
The HTML5 and Video.js hosts apply the same ownership rule while a playback
diagnostic is visible: `WebPlayerViewComponent` passes
`interactionEnabled = visiblePlaybackDiagnostic() === null`, and both
components bind that value to `showControls` and
`shortcutsEnabled`. If the active player shell owns DOM fullscreen, its host
exits fullscreen before hiding the controls so the sibling diagnostic banner
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
@@ -263,7 +274,7 @@ 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 and HTML5 engine bridge
## Web adapter and web-engine bridges
`WebVideoControlsAdapter` can translate an `HTMLVideoElement` into the shared
contract. It uses DOM/media events and accepts optional engine-specific track
@@ -308,15 +319,51 @@ 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.
`VjsPlayerControlsBridge` attaches the component-scoped adapter to the current
Video.js Tech `<video>`. Video.js can replace that element during `reset()`, so
the component reacquires it after `playerreset`, rebinds native media events,
and attaches the bridge to the replacement before activating the new source.
Audio and subtitle helpers assign source-lifetime IDs through `WeakMap`s, so
track reordering or list refreshes do not change the IDs exposed to shared
controls.
Video.js subtitle selection preserves an explicit shared-controls override,
including the `-1` off selection. Without an override, disabling the global
caption preference suppresses the currently showing track and restores that
same track when the preference returns, if it still belongs to the active
source. Source changes reset both stable-ID maps and per-source subtitle state.
The bridge reads duration through `player.duration()` because Video.js may
correct or project a value that differs from the current Tech element.
For reset-driven source changes, raw MPEG-TS activation is deferred until
`playerreset`. Video.js can otherwise defer `reset()` behind a pending
`play()`, so a dedicated coordinator pauses first and calls `reset()` only
after `player.paused()` is true. Multiple reset-required changes coalesce, and
every `playerreset` rebinds the current Tech before applying only the latest
desired source. The coordinator snapshots actual Video.js volume, suppresses
the reset-generated volume=1 event, restores the snapshot, and tracks whether a
pre-ready reset already applied the source. An authoritative live/VOD metadata
change restarts active raw MPEG-TS with the corrected mode. For MPEG-TS VOD,
the session projects the last finite seekable or buffered end through
`player.duration()`.
`web-video-controls.host.ts` still contains small generic
attachment/projection helpers. Video.js and ArtPlayer do not call them yet.
attachment/projection helpers. The Video.js integration uses its dedicated
bridge directly, and ArtPlayer does not call them yet.
The rollout symbols are:
| Symbol | Default | Current effect |
| ------------------------------------ | ------------: | -------------------------------------------------------------------------------------------------------------------- |
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Keeps existing web-player skins active in normal runtime builds. |
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view consumed by the HTML5 host to switch atomically between native and shared controls. |
| Symbol | Default | Current effect |
| ------------------------------------ | ------------: | -------------------------------------------------------------------------------------------------------------------------------- |
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Keeps existing web-player skins active in normal runtime builds. |
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view consumed by HTML5 and Video.js to switch atomically between their existing and shared controls. |
With the token enabled, Video.js also disables native controls, Video.js
single-click and double-click actions, Video.js hotkeys, and spatial navigation.
This leaves surface clicks, double-click fullscreen, and playback shortcuts
owned exclusively by `app-player-controls`. With the token disabled, existing
Video.js options, plugins, skin, audio-track menu, and series navigation remain
unchanged.
## Embedded MPV rendering constraints
@@ -367,9 +414,8 @@ renderer, bounds, and platform details.
The remaining design seams are:
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.
1. **ArtPlayer** — add an engine-specific adapter/bridge, consume the rollout
token, and remove its skin only when shared controls are active.
2. **Native-view UI** — retain the compositor-safe dock unless the native
engine's compositing architecture changes independently. A native-view
migration is not part of the frame-copy rollout.
@@ -439,5 +485,34 @@ libs/ui/playback/src/lib/html-video-player/
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.
flag-off post-play caption behavior.
The guarded Video.js integration lives in:
```text
libs/ui/playback/src/lib/vjs-player/
├── vjs-audio-tracks.ts
├── vjs-mpegts-session.ts
├── vjs-player-controls.bridge.ts
├── vjs-player-reset-coordinator.ts
├── vjs-player-setup.ts
├── vjs-player.component.ts
├── vjs-player.component.html
├── vjs-text-tracks.ts
└── vjs-video-element-session.ts
```
`VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`.
Its bridge and track helpers own current-Tech attachment, source-lifetime track
identity, caption preference/override projection, and exact listener cleanup.
`VjsMpegTsSession` owns raw MPEG-TS attachment and VOD duration correction,
`VjsPlayerResetCoordinator` owns pause/coalesced-reset ordering and volume
preservation, while `VjsVideoElementSession` owns native Tech-element
playback/ended events.
Focused specs cover the Video.js flag-off compatibility path, shared-controls
rendering and interaction gating, Tech replacement, track-list lifecycle and
stable IDs, subtitle preference/explicit-off behavior, pause/coalesced-reset
ordering and volume preservation for MPEG-TS, duration projection, and
collaborator teardown. ArtPlayer skin removal and persistent/background player
ownership have not landed.
@@ -0,0 +1,200 @@
# Video.js Shared Controls Replacement Implementation Plan
> Execute with test-driven development. Each behavior change starts with a
> focused failing test, then the minimum implementation, then targeted and broad
> verification.
## Task 1: Extract focused Video.js types
Files:
- Add `libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts`.
- Update `vjs-player.component.ts` imports.
Steps:
1. Move only the API shapes shared by collaborators.
2. Preserve existing runtime behavior.
3. Run the existing Video.js component spec.
## Task 2: Extract and harden audio-track ownership
Files:
- Add `vjs-audio-tracks.ts`.
- Add `vjs-audio-tracks.spec.ts`.
- Update `vjs-player.component.ts`.
Tests first:
- project labels and stable IDs;
- select exactly one valid track;
- stale/invalid ID is a no-op;
- legacy menu remains available flag-off;
- add/remove/change/labelchange refresh through stable callbacks;
- identity-aware bind and exact cleanup.
Implementation:
- Preserve the useful logging/menu behavior from #1153.
- Reset IDs at each source boundary.
- Use the same collaborator in legacy and shared modes.
## Task 3: Add Video.js subtitle-track ownership
Files:
- Add `vjs-text-tracks.ts`.
- Add `vjs-text-tracks.spec.ts`.
Tests first:
- filter captions/subtitles;
- stable IDs within a source;
- select one track and disable the rest;
- `-1` explicitly disables all tracks;
- stale ID is a no-op;
- global caption suppression/restoration;
- explicit off survives preference and track-list events;
- source reset and exact listener cleanup.
Implementation:
- Use `player.textTracks()`.
- Keep the helper Video.js-specific.
## Task 4: Extract raw MPEG-TS session
Files:
- Add `vjs-mpegts-session.ts`.
- Add `vjs-mpegts-session.spec.ts`.
- Remove the corresponding code from `vjs-player.component.ts`.
Tests first:
- source detection including query-declared HLS;
- authoritative live/VOD option;
- attach/load/play on the supplied current Tech video;
- VOD duration normalization;
- error classification;
- missing video handling;
- idempotent destroy and exact listener cleanup.
Implementation:
- Keep raw MPEG-TS attachment in the session and put pause/coalesced-reset
ordering in a focused reset coordinator.
- Make `start` accept the current Tech video explicitly.
## Task 5: Add a rebindable Tech video session
Files:
- Add `vjs-video-element-session.ts`.
- Add `vjs-video-element-session.spec.ts`.
Tests first:
- attach once;
- rebind A → B removes all listeners from A and adds them once to B;
- loaded/playing clear the diagnostic;
- ended emits once;
- destroy is idempotent.
## Task 6: Add the shared-controls bridge
Files:
- Add `vjs-player-controls.bridge.ts`.
- Add `vjs-player-controls.bridge.spec.ts`.
Tests first:
- attach to the current Tech video;
- adapter accessors expose live/VOD, duration, audio, and subtitles;
- rebind detaches A and attaches B exactly once;
- source clear resets track state;
- input refresh reapplies captions and classification;
- destroy cleans track listeners and adapter exactly once.
Implementation:
- Use the component-scoped `WebVideoControlsAdapter`.
- Delegate track ownership to the focused collaborators.
## Task 7: Integrate the component and host
Files:
- Update `vjs-player.component.ts`, `.html`, and focused specs.
- Add `vjs-player.component.shared-controls.spec.ts`.
- Update `web-player-view.component.html`.
- Update `web-player-view.component.shared-controls.spec.ts`.
Tests first:
- flag-off preserves Video.js chrome and legacy navigation;
- flag-on renders exactly one shared controls UI;
- flag-on disables native controls, Video.js gestures/hotkeys, and spatial nav;
- shared controls use the real shell;
- diagnostic gating disables controls/shortcuts and exits owned fullscreen;
- both host Video.js branches receive `showCaptions` and
`interactionEnabled`;
- `playerreset` rebinds the video session and controls bridge;
- reset-required changes coalesce while applying only the latest desired
source;
- active playback pauses before `reset()`, and reset volume=1 never overwrites
the user's actual volume;
- pre-ready reset does not start MPEG-TS twice;
- raw MPEG-TS restarts when authoritative live/VOD metadata changes.
Implementation ordering:
1. Create Video.js and register stable player lifecycle handlers.
2. On initial ready, bind the current Tech video and active source.
3. Before reset, snapshot actual volume, pause, and coalesce the latest desired
source.
4. On any `playerreset`, restore volume, rebind sessions, and apply only the
latest desired source before starting MPEG-TS.
5. Track whether the current Tech already has its source so pre-ready reset
cannot double-start playback.
6. Destroy coordinator/bridge/session collaborators before `player.dispose()`.
## Task 8: Reduce file baselines and update canonical docs
Files:
- Update `tools/eslint/max-lines-baseline.mjs`.
- Update `docs/architecture/player-controls-contract.md`.
- Update `AGENTS.md` and `CLAUDE.md` only where their current shared-controls
description becomes stale.
- Update `web-player-controls.flag.ts` documentation.
Checks:
- `vjs-player.component.ts` is below 400 lines.
- Split the component spec if needed and remove stale max-lines baselines.
## Task 9: Verification and PR loop
Run:
```bash
pnpm nx test ui-playback --skip-nx-cache
pnpm nx lint ui-playback --skip-nx-cache
pnpm run typecheck:ci
pnpm run coverage:ci
pnpm run lint
git diff --check
```
Then:
1. Push the replacement branch.
2. Open a replacement PR that credits #1153 and states it supersedes it.
3. Trigger fresh Codex and Greptile reviews.
4. Resolve only verified findings.
5. Require full green CI.
6. Squash merge the replacement.
7. Close #1153 with thanks and a link to the merged replacement.
@@ -0,0 +1,174 @@
# Video.js Shared Controls Replacement Design
## Context
PR #1153 proposed wiring Video.js into the shared `app-player-controls`
layer. Its Video.js-specific ideas remain useful, but the stacked PR also
contains superseded embedded-MPV and HTML5 work and cannot be merged directly.
The replacement is built on the current `master`, after:
- #1193 established frame-copy embedded MPV with shared controls.
- #1194 established the current web-player controls contract, authoritative
live/VOD metadata, diagnostic interaction gating, and source-owned cleanup.
The shared-controls feature flag remains disabled by default. Flag-off behavior
must stay unchanged.
## Goals
- Render the shared controls over Video.js when the feature flag is enabled.
- Preserve the native Video.js chrome and legacy series navigation when it is
disabled.
- Expose Video.js audio and subtitle tracks through the common adapter.
- Rebind all DOM-backed state when Video.js replaces its Tech `<video>`.
- Keep diagnostics usable by disabling player interaction and exiting only the
fullscreen session owned by the Video.js shell.
- Reduce `vjs-player.component.ts` below the 400-line hard limit.
## Non-goals
- Enable shared web controls by default.
- Add Video.js quality selection to the generic controls contract.
- Add generic aspect-ratio or PiP commands.
- Generalize the HTML5 text-track helper before a second implementation proves
the abstractions are identical.
## Key lifecycle decision
Video.js 8.23.4 replaces the Tech `<video>` after `player.reset()`. A one-time
adapter attachment is therefore invalid.
The replacement must treat the Tech video as a session-owned resource:
1. Acquire it from `player.tech({ IWillNotUseThisInPlugins: true }).el()`.
2. Detach listeners and the adapter from the previous element.
3. Attach them to the current element.
4. Repeat after every `playerreset`.
5. Destroy the bridge/session before `player.dispose()`.
Raw MPEG-TS startup must wait for `playerreset`, but Video.js does not attach a
request token to that event and can defer `reset()` behind a pending
`play()`. The component therefore pauses first, coalesces reset-required source
changes, and calls `reset()` only after `player.paused()` is true. Every
`playerreset` rebinds the current Tech and applies the latest desired source,
so a late reset cannot restore a superseded source.
## Component boundaries
### `vjs-player.types.ts`
Owns the focused Video.js API types used by the component and collaborators.
### `vjs-video-element-session.ts`
Owns native Tech video listeners used in both flag states:
- `loadeddata` / `playing` clear playback diagnostics.
- `ended` emits the playback-ended output.
### `vjs-player-reset-coordinator.ts`
Owns pause-before-reset ordering, coalescing, actual-volume snapshots, reset
volume-change suppression, and whether the desired source is already applied
to the current Tech. This also prevents a pre-ready `playerreset` from starting
raw MPEG-TS twice.
- Rebind detaches the old video before attaching the new one.
### `vjs-audio-tracks.ts`
Owns:
- legacy Video.js audio-menu logging/setup;
- stable per-source IDs for shared controls;
- valid selection and exact track-list listener cleanup.
Invalid or stale IDs are no-ops.
### `vjs-text-tracks.ts`
Uses `player.textTracks()` because VHS remote subtitles are represented there.
It:
- exposes only `captions` and `subtitles`;
- assigns stable per-source IDs with a `WeakMap`;
- treats `-1` as explicit off;
- keeps explicit off through preference and track-list events;
- suppresses the selected default track while global captions are disabled;
- disables non-selected tracks so VHS does not keep multiple tracks active.
### `vjs-mpegts-session.ts`
Owns raw MPEG-TS create/attach/load/play, VOD duration normalization,
diagnostics, listener cleanup, and idempotent teardown.
### `vjs-player-controls.bridge.ts`
Owns the component-scoped `WebVideoControlsAdapter` binding:
- attaches to the current Tech video;
- rebinds only when the Tech element changes;
- delegates audio/subtitle access to the track collaborators;
- uses `player.duration()` as the corrected duration source;
- clears source-specific IDs before source replacement;
- refreshes live/VOD and caption inputs;
- detaches exactly once on destroy.
## Interaction ownership
When shared controls are enabled:
- the template does not expose native `controls`;
- Video.js receives `controls: false`;
- Video.js `userActions.click`, `doubleClick`, and `hotkeys` are disabled;
- Video.js spatial navigation is disabled;
- the stable `.vjs-player-shell` is the shared interaction/fullscreen surface;
- `showControls` and `shortcutsEnabled` follow `interactionEnabled`;
- a diagnostic exits fullscreen only when that shell owns
`document.fullscreenElement`.
When the flag is disabled, existing Video.js options, chrome, plugins, and
legacy series navigation remain unchanged.
## Source transitions
### Normal Video.js source
1. Clear source-specific bridge/track state.
2. Destroy any raw MPEG-TS session.
3. Call `player.src(newSource)`.
4. Activate the new bridge source and refresh classification.
### Raw MPEG-TS source or empty source
1. Clear source-specific bridge/track state.
2. Destroy the current raw MPEG-TS session.
3. Record the latest desired source and request one coalesced reset.
4. Snapshot the actual Video.js volume and pause active playback.
5. Call `player.reset()` only after `player.paused()` is true.
6. On any `playerreset`, suppress/reset Video.js's temporary volume=1 change,
restore the snapshot, and rebind the current Tech video.
7. Apply only the latest desired source and start mpegts.js when applicable.
If authoritative `isLive` metadata changes for an active raw MPEG-TS URL, the
same reset flow restarts mpegts.js with the corrected live/VOD mode and duration
listener policy.
## Testing strategy
- Focused unit suites for video-session, audio tracks, text tracks, MPEG-TS,
and the controls bridge.
- Component integration tests for both flag states, fullscreen/diagnostic
gating, reset ordering, and output forwarding.
- Host tests that pass `interactionEnabled` and `showCaptions` to both
Video.js template branches.
- Existing `ui-playback` tests/lint, workspace typecheck, coverage, full lint,
Web E2E, and Electron E2E before merge.
## Attribution
The replacement preserves the useful Video.js-specific direction from Lars
Emig's #1153, especially the audio-track extraction and default-off dual-chrome
model, while rebuilding it against the current lifecycle and controls
architecture.
@@ -4,10 +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. 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.
* DEFAULT OFF. The built-in HTML5 and Video.js players consume the injectable
* {@link WEB_PLAYER_SHARED_CONTROLS} token and switch atomically between their
* existing chrome and shared controls. ArtPlayer does not consume the token
* yet.
*/
export const WEB_PLAYER_SHARED_CONTROLS_ENABLED = false;
@@ -0,0 +1,373 @@
import {
VjsAudioTracks,
logVjsAudioTracks,
setupVjsAudioTrackMenu,
type VjsAudioTracksConfig,
} from './vjs-audio-tracks';
import type {
VideoJsAudioTrack,
VideoJsAudioTrackList,
VideoJsPlayer,
} from './vjs-player.types';
type AudioTrackPlayer = Pick<
VideoJsPlayer,
'audioTracks' | 'getChild' | 'tech'
>;
type TestAudioTrackList = VideoJsAudioTrackList & {
emit: (type: string) => void;
};
function createTrackList(tracks: VideoJsAudioTrack[]): TestAudioTrackList {
const listeners = new Map<
string,
Set<EventListenerOrEventListenerObject>
>();
const list = {
length: tracks.length,
addEventListener: jest.fn(
(type: string, listener: EventListenerOrEventListenerObject) => {
const eventListeners = listeners.get(type) ?? new Set();
eventListeners.add(listener);
listeners.set(type, eventListeners);
}
),
removeEventListener: jest.fn(
(type: string, listener: EventListenerOrEventListenerObject) => {
listeners.get(type)?.delete(listener);
}
),
emit: (type: string) => {
const event = { type } as Event;
for (const listener of listeners.get(type) ?? []) {
if (typeof listener === 'function') {
listener(event);
} else {
listener.handleEvent(event);
}
}
},
} as TestAudioTrackList;
tracks.forEach((track, index) => {
list[index] = track;
});
return list;
}
function createPlayer(
audioTracks: () => VideoJsAudioTrackList | null,
overrides: Partial<AudioTrackPlayer> = {}
): AudioTrackPlayer {
return {
audioTracks,
getChild: jest.fn(() => null),
tech: jest.fn(() => null),
...overrides,
};
}
function createAudioTracks(
player: AudioTrackPlayer,
refresh = jest.fn()
): VjsAudioTracks {
const config: VjsAudioTracksConfig = { player, refresh };
return new VjsAudioTracks(config);
}
describe('VjsAudioTracks', () => {
it('projects labels and keeps track IDs stable when the list order changes', () => {
const english: VideoJsAudioTrack = {
label: 'English',
enabled: true,
};
const german: VideoJsAudioTrack = {
language: 'de',
enabled: false,
};
const unnamed: VideoJsAudioTrack = { enabled: false };
const list = createTrackList([english, german, unnamed]);
const audioTracks = createAudioTracks(createPlayer(() => list));
audioTracks.bind();
expect(audioTracks.getAudioTracks()).toEqual([
{ id: 0, label: 'English', selected: true },
{ id: 1, label: 'de', selected: false },
{ id: 2, label: 'Audio 3', selected: false },
]);
list[0] = german;
list[1] = english;
expect(audioTracks.getAudioTracks()).toEqual([
{ id: 1, label: 'de', selected: false },
{ id: 0, label: 'English', selected: true },
{ id: 2, label: 'Audio 3', selected: false },
]);
});
it('selects exactly one valid projected track', () => {
const first: VideoJsAudioTrack = { enabled: true };
const second: VideoJsAudioTrack = { enabled: false };
const third: VideoJsAudioTrack = { enabled: false };
const list = createTrackList([first, second, third]);
const audioTracks = createAudioTracks(createPlayer(() => list));
audioTracks.bind();
const secondId = audioTracks.getAudioTracks()[1].id;
audioTracks.setAudioTrack(secondId);
expect([first.enabled, second.enabled, third.enabled]).toEqual([
false,
true,
false,
]);
});
it('keeps selection unchanged for invalid and stale track IDs', () => {
const first: VideoJsAudioTrack = { enabled: true };
const removed: VideoJsAudioTrack = { enabled: false };
const list = createTrackList([first, removed]);
const audioTracks = createAudioTracks(createPlayer(() => list));
audioTracks.bind();
const staleId = audioTracks.getAudioTracks()[1].id;
list.length = 1;
delete list[1];
audioTracks.setAudioTrack(staleId);
audioTracks.setAudioTrack(99);
audioTracks.setAudioTrack(0.5);
expect(first.enabled).toBe(true);
});
it('binds stable add, remove, change, and label callbacks once per list', () => {
const list = createTrackList([
{ label: 'English', enabled: true },
{ label: 'German', enabled: false },
]);
const refresh = jest.fn();
const audioTracks = createAudioTracks(
createPlayer(() => list),
refresh
);
audioTracks.bind();
audioTracks.bind();
expect(list.addEventListener).toHaveBeenCalledTimes(4);
expect(
(list.addEventListener as jest.Mock).mock.calls.map(
([type]) => type
)
).toEqual(['addtrack', 'removetrack', 'change', 'labelchange']);
list.emit('addtrack');
list.emit('removetrack');
list.emit('change');
list.emit('labelchange');
expect(refresh).toHaveBeenCalledTimes(4);
});
it('rebinds by list identity and removes the exact callbacks', () => {
const firstList = createTrackList([{ enabled: true }]);
const secondList = createTrackList([{ enabled: true }]);
let currentList: VideoJsAudioTrackList | null = firstList;
const audioTracks = createAudioTracks(createPlayer(() => currentList));
audioTracks.bind();
const firstListeners = new Map(
(firstList.addEventListener as jest.Mock).mock.calls.map(
([type, listener]) => [type, listener]
)
);
currentList = secondList;
audioTracks.bind();
expect(firstList.removeEventListener).toHaveBeenCalledTimes(4);
for (const [type, listener] of firstListeners) {
expect(firstList.removeEventListener).toHaveBeenCalledWith(
type,
listener
);
}
expect(secondList.addEventListener).toHaveBeenCalledTimes(4);
const secondListeners = new Map(
(secondList.addEventListener as jest.Mock).mock.calls.map(
([type, listener]) => [type, listener]
)
);
audioTracks.clear();
audioTracks.clear();
expect(secondList.removeEventListener).toHaveBeenCalledTimes(4);
for (const [type, listener] of secondListeners) {
expect(secondList.removeEventListener).toHaveBeenCalledWith(
type,
listener
);
}
});
it('resets the per-source ID allocation without replacing the list', () => {
const list = createTrackList([{ label: 'Old A' }, { label: 'Old B' }]);
const audioTracks = createAudioTracks(createPlayer(() => list));
audioTracks.bind();
expect(audioTracks.getAudioTracks().map(({ id }) => id)).toEqual([
0, 1,
]);
list[0] = { label: 'New A' };
list[1] = { label: 'New B' };
audioTracks.resetSource();
expect(audioTracks.getAudioTracks()).toEqual([
{ id: 0, label: 'New A', selected: false },
{ id: 1, label: 'New B', selected: false },
]);
});
it('keeps the legacy menu refreshed when tracks are added or removed', () => {
const list = createTrackList([
{ label: 'English', enabled: true },
{ label: 'German', enabled: false },
]);
const audioButton = { show: jest.fn(), update: jest.fn() };
const controlBar = {
getChild: jest.fn(() => audioButton),
addChild: jest.fn(),
};
const player = createPlayer(() => list, {
getChild: jest.fn(() => controlBar),
});
const audioTracks = createAudioTracks(player);
audioTracks.bind();
list.emit('addtrack');
list.emit('removetrack');
expect(audioButton.show).toHaveBeenCalledTimes(2);
expect(audioButton.update).toHaveBeenCalledTimes(2);
});
});
describe('legacy Video.js audio-track helpers', () => {
it('skips the menu when fewer than two tracks are available', () => {
const getChild = jest.fn();
setupVjsAudioTrackMenu(
createPlayer(() => createTrackList([{ enabled: true }]), {
getChild,
})
);
setupVjsAudioTrackMenu(
createPlayer(() => null, {
getChild,
})
);
expect(getChild).not.toHaveBeenCalled();
});
it('reuses either supported audio button name', () => {
const lowerButton = { show: jest.fn(), update: jest.fn() };
const lowerControlBar = {
getChild: jest.fn((name: string) =>
name === 'audioTrackButton' ? lowerButton : null
),
addChild: jest.fn(),
};
setupVjsAudioTrackMenu(
createPlayer(
() => createTrackList([{ enabled: true }, { enabled: false }]),
{ getChild: jest.fn(() => lowerControlBar) }
)
);
const capitalButton = { show: jest.fn(), update: jest.fn() };
const capitalControlBar = {
getChild: jest.fn((name: string) =>
name === 'AudioTrackButton' ? capitalButton : null
),
addChild: jest.fn(),
};
setupVjsAudioTrackMenu(
createPlayer(
() => createTrackList([{ enabled: true }, { enabled: false }]),
{ getChild: jest.fn(() => capitalControlBar) }
)
);
expect(lowerControlBar.addChild).not.toHaveBeenCalled();
expect(lowerButton.show).toHaveBeenCalledTimes(1);
expect(lowerButton.update).toHaveBeenCalledTimes(1);
expect(capitalControlBar.addChild).not.toHaveBeenCalled();
expect(capitalButton.show).toHaveBeenCalledTimes(1);
expect(capitalButton.update).toHaveBeenCalledTimes(1);
});
it('adds the legacy audio button when the control bar lacks one', () => {
const audioButton = { show: jest.fn(), update: jest.fn() };
const controlBar = {
getChild: jest.fn(() => null),
addChild: jest.fn(() => audioButton),
};
setupVjsAudioTrackMenu(
createPlayer(
() => createTrackList([{ enabled: true }, { enabled: false }]),
{ getChild: jest.fn(() => controlBar) }
)
);
expect(controlBar.addChild).toHaveBeenCalledWith(
'audioTrackButton',
{}
);
expect(audioButton.show).toHaveBeenCalledTimes(1);
expect(audioButton.update).toHaveBeenCalledTimes(1);
});
it('logs tracks and reads main or legacy master HLS audio groups', () => {
const list = createTrackList([
{
label: 'English',
language: 'en',
enabled: true,
kind: 'main',
},
]);
const mainTech = jest.fn(() => ({
vhs: {
playlists: {
main: { mediaGroups: { AUDIO: { english: {} } } },
},
},
}));
const masterTech = jest.fn(() => ({
vhs: {
playlists: {
master: { mediaGroups: { AUDIO: { german: {} } } },
},
},
}));
expect(() =>
logVjsAudioTracks(createPlayer(() => list, { tech: mainTech }))
).not.toThrow();
expect(() =>
logVjsAudioTracks(createPlayer(() => list, { tech: masterTech }))
).not.toThrow();
expect(mainTech).toHaveBeenCalledWith({
IWillNotUseThisInPlugins: true,
});
expect(masterTech).toHaveBeenCalledWith({
IWillNotUseThisInPlugins: true,
});
});
});
@@ -0,0 +1,247 @@
import { createDevLogger } from '@iptvnator/shared/interfaces';
import type { PlayerTrack } from '../player-controls/player-controls.model';
import type {
VideoJsAudioTrack,
VideoJsAudioTrackList,
VideoJsPlayer,
} from './vjs-player.types';
const AUDIO_TRACK_LISTENERS = {
addtrack: 'addtrack',
removetrack: 'removetrack',
change: 'change',
labelchange: 'labelchange',
} as const;
const debugVjsAudio = createDevLogger('VjsPlayer');
export type VjsAudioTrackPlayer = Pick<
VideoJsPlayer,
'audioTracks' | 'getChild' | 'tech'
>;
export interface VjsAudioTracksConfig {
player: VjsAudioTrackPlayer;
refresh: () => void;
}
export function logVjsAudioTracks(player: VjsAudioTrackPlayer): void {
const audioTracks = player.audioTracks();
debugVjsAudio('[AudioTrack] Audio tracks count:', audioTracks?.length ?? 0);
if (!audioTracks) {
return;
}
for (let index = 0; index < audioTracks.length; index += 1) {
const track = audioTracks[index];
debugVjsAudio(
`[AudioTrack] Track ${index}: label="${track.label}", language="${track.language}", enabled=${track.enabled}, kind="${track.kind}"`
);
}
const tech =
typeof player.tech === 'function'
? player.tech({ IWillNotUseThisInPlugins: true })
: null;
const audioMediaGroups =
tech?.vhs?.playlists?.main?.mediaGroups?.AUDIO ??
tech?.vhs?.playlists?.master?.mediaGroups?.AUDIO;
if (audioMediaGroups) {
debugVjsAudio(
'[AudioTrack] HLS AUDIO media groups:',
JSON.stringify(Object.keys(audioMediaGroups))
);
} else {
debugVjsAudio(
'[AudioTrack] HLS AUDIO media groups: none found in playlist metadata'
);
}
}
export function setupVjsAudioTrackMenu(player: VjsAudioTrackPlayer): void {
const audioTracks = player.audioTracks();
debugVjsAudio(
'[AudioTrack] setupAudioTrackMenu called, tracks:',
audioTracks?.length ?? 0
);
if (!audioTracks || audioTracks.length <= 1) {
debugVjsAudio(
'[AudioTrack] Skipping menu: need >1 tracks, have',
audioTracks?.length ?? 0
);
debugVjsAudio(
'[AudioTrack] If VLC/MPV show more tracks, the HLS manifest likely does not expose alternate audio via EXT-X-MEDIA'
);
return;
}
const controlBar = player.getChild('controlBar');
if (!controlBar) {
return;
}
let audioButton =
controlBar.getChild?.('audioTrackButton') ??
controlBar.getChild?.('AudioTrackButton');
if (!audioButton && controlBar.addChild) {
audioButton = controlBar.addChild('audioTrackButton', {});
}
if (audioButton) {
audioButton.show?.();
audioButton.update?.();
}
}
export class VjsAudioTracks {
private trackList: VideoJsAudioTrackList | null = null;
private trackIds = new WeakMap<VideoJsAudioTrack, number>();
private nextTrackId = 0;
private readonly handleAddTrack: EventListener = () => {
debugVjsAudio(
'[AudioTrack] addtrack event fired, total tracks:',
this.trackList?.length ?? 0
);
logVjsAudioTracks(this.config.player);
setupVjsAudioTrackMenu(this.config.player);
this.config.refresh();
};
private readonly handleRemoveTrack: EventListener = () => {
debugVjsAudio(
'[AudioTrack] removetrack event fired, total tracks:',
this.trackList?.length ?? 0
);
logVjsAudioTracks(this.config.player);
setupVjsAudioTrackMenu(this.config.player);
this.config.refresh();
};
private readonly handleChange: EventListener = () => {
logVjsAudioTracks(this.config.player);
this.config.refresh();
};
private readonly handleLabelChange: EventListener = () => {
logVjsAudioTracks(this.config.player);
this.config.refresh();
};
constructor(private readonly config: VjsAudioTracksConfig) {}
bind(): void {
const trackList = this.config.player.audioTracks();
if (trackList === this.trackList) {
return;
}
this.detachTrackList();
this.trackList = trackList;
if (typeof trackList?.addEventListener !== 'function') {
return;
}
trackList.addEventListener(
AUDIO_TRACK_LISTENERS.addtrack,
this.handleAddTrack
);
trackList.addEventListener(
AUDIO_TRACK_LISTENERS.removetrack,
this.handleRemoveTrack
);
trackList.addEventListener(
AUDIO_TRACK_LISTENERS.change,
this.handleChange
);
trackList.addEventListener(
AUDIO_TRACK_LISTENERS.labelchange,
this.handleLabelChange
);
}
clear(): void {
this.detachTrackList();
this.trackList = null;
this.resetSource();
}
resetSource(): void {
this.trackIds = new WeakMap<VideoJsAudioTrack, number>();
this.nextTrackId = 0;
}
getAudioTracks(): PlayerTrack[] {
const tracks = this.trackList;
if (!tracks) {
return [];
}
const result: PlayerTrack[] = [];
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
if (!track) {
continue;
}
let id = this.trackIds.get(track);
if (id === undefined) {
id = this.nextTrackId;
this.nextTrackId += 1;
this.trackIds.set(track, id);
}
result.push({
id,
label: track.label || track.language || `Audio ${id + 1}`,
selected: track.enabled === true,
});
}
return result;
}
setAudioTrack(id: number): void {
if (!Number.isInteger(id) || !this.trackList) {
return;
}
const tracks = this.trackList;
let selectedTrack: VideoJsAudioTrack | null = null;
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
if (track && this.trackIds.get(track) === id) {
selectedTrack = track;
break;
}
}
if (!selectedTrack) {
return;
}
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
if (track) {
track.enabled = track === selectedTrack;
}
}
}
private detachTrackList(): void {
const trackList = this.trackList;
if (typeof trackList?.removeEventListener !== 'function') {
return;
}
trackList.removeEventListener(
AUDIO_TRACK_LISTENERS.addtrack,
this.handleAddTrack
);
trackList.removeEventListener(
AUDIO_TRACK_LISTENERS.removetrack,
this.handleRemoveTrack
);
trackList.removeEventListener(
AUDIO_TRACK_LISTENERS.change,
this.handleChange
);
trackList.removeEventListener(
AUDIO_TRACK_LISTENERS.labelchange,
this.handleLabelChange
);
}
}
@@ -0,0 +1,251 @@
const createPlayerMock = jest.fn();
const isSupportedMock = jest.fn(() => true);
let VjsMpegTsSession: typeof import('./vjs-mpegts-session').VjsMpegTsSession;
jest.unstable_mockModule('mpegts.js', () => ({
default: {
Events: { ERROR: 'error' },
createPlayer: createPlayerMock,
isSupported: isSupportedMock,
},
}));
describe('VjsMpegTsSession', () => {
beforeAll(async () => {
({ VjsMpegTsSession } = await import('./vjs-mpegts-session'));
});
beforeEach(() => {
createPlayerMock.mockReset();
isSupportedMock.mockReset().mockReturnValue(true);
});
it('recognizes raw MPEG-TS sources without stealing declared HLS URLs', () => {
const session = createSession().session;
expect(
session.isSupportedSource(
'https://example.test/play?extension=m3u8&token=x'
)
).toBe(false);
expect(
session.isSupportedSource(
'https://example.test/live.php?extension=ts'
)
).toBe(true);
expect(
session.isSupportedSource(
'https://example.test/live.php?stream=123'
)
).toBe(true);
isSupportedMock.mockReturnValue(false);
expect(
session.isSupportedSource('https://example.test/live/stream.ts')
).toBe(false);
expect(session.isSupportedSource()).toBe(false);
});
it('attaches VOD playback to the explicit current Tech video', () => {
const mpegTsPlayer = createMpegTsPlayer();
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session } = createSession({ isLive: false });
session.start('https://example.test/archive/movie.ts', video);
expect(createPlayerMock).toHaveBeenCalledWith({
type: 'mpegts',
isLive: false,
url: 'https://example.test/archive/movie.ts',
});
expect(mpegTsPlayer.attachMediaElement).toHaveBeenCalledWith(video);
expect(mpegTsPlayer.load).toHaveBeenCalledTimes(1);
expect(mpegTsPlayer.play).toHaveBeenCalledTimes(1);
});
it('handles an asynchronous autoplay rejection', () => {
const catchRejection = jest.fn();
const mpegTsPlayer = createMpegTsPlayer();
mpegTsPlayer.play.mockReturnValue({
catch: catchRejection,
} as unknown as Promise<void>);
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session } = createSession();
session.start('https://example.test/live/stream.ts', video);
expect(catchRejection).toHaveBeenCalledWith(expect.any(Function));
expect(
catchRejection.mock.calls[0][0](new Error('autoplay blocked'))
).toBeUndefined();
});
it('normalizes VOD duration from seekable and then buffered ranges', async () => {
const mpegTsPlayer = createMpegTsPlayer();
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
Object.defineProperty(video, 'seekable', {
configurable: true,
value: createTimeRanges([[0, 164.072]]),
});
Object.defineProperty(video, 'buffered', {
configurable: true,
value: createTimeRanges([[0, 200]]),
});
const { session, duration } = createSession({ isLive: false });
session.start('https://example.test/archive/movie.ts', video);
video.dispatchEvent(new Event('progress'));
await Promise.resolve();
expect(duration).toHaveBeenCalledWith(164.072);
Object.defineProperty(video, 'seekable', {
configurable: true,
value: createTimeRanges([]),
});
video.dispatchEvent(new Event('durationchange'));
expect(duration).toHaveBeenCalledWith(200);
});
it('classifies engine errors with Video.js source metadata', () => {
const mpegTsPlayer = createMpegTsPlayer();
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session, emitPlaybackIssue } = createSession();
session.start('https://example.test/live/stream.ts', video);
mpegTsPlayer.emit(
'error',
'NetworkError',
'FetchError',
new Error('CORS blocked')
);
expect(emitPlaybackIssue).toHaveBeenCalledWith(
expect.objectContaining({
code: 'browser-access-error',
source: 'mpegts',
sourceUrl: 'https://example.test/live/stream.ts',
player: 'videojs',
})
);
});
it('replaces an active session and removes listeners from the old video', () => {
const firstPlayer = createMpegTsPlayer();
const secondPlayer = createMpegTsPlayer();
createPlayerMock
.mockReturnValueOnce(firstPlayer)
.mockReturnValueOnce(secondPlayer);
const firstVideo = document.createElement('video');
const secondVideo = document.createElement('video');
const removeEventListener = jest.spyOn(
firstVideo,
'removeEventListener'
);
const { session } = createSession({ isLive: false });
session.start('https://example.test/archive/one.ts', firstVideo);
session.start('https://example.test/archive/two.ts', secondVideo);
expect(removeEventListener).toHaveBeenCalledTimes(5);
expect(firstPlayer.off).toHaveBeenCalledWith(
'error',
expect.any(Function)
);
expect(firstPlayer.pause).toHaveBeenCalledTimes(1);
expect(firstPlayer.unload).toHaveBeenCalledTimes(1);
expect(firstPlayer.detachMediaElement).toHaveBeenCalledTimes(1);
expect(firstPlayer.destroy).toHaveBeenCalledTimes(1);
expect(secondPlayer.attachMediaElement).toHaveBeenCalledWith(
secondVideo
);
});
it('destroys idempotently and ignores events after teardown', () => {
const mpegTsPlayer = createMpegTsPlayer();
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session, duration, emitPlaybackIssue } = createSession({
isLive: false,
});
session.start('https://example.test/archive/movie.ts', video);
duration.mockClear();
session.destroy();
session.destroy();
video.dispatchEvent(new Event('progress'));
mpegTsPlayer.emit(
'error',
'NetworkError',
'FetchError',
new Error('failed')
);
expect(duration).not.toHaveBeenCalled();
expect(emitPlaybackIssue).not.toHaveBeenCalled();
expect(mpegTsPlayer.pause).toHaveBeenCalledTimes(1);
expect(mpegTsPlayer.unload).toHaveBeenCalledTimes(1);
expect(mpegTsPlayer.detachMediaElement).toHaveBeenCalledTimes(1);
expect(mpegTsPlayer.destroy).toHaveBeenCalledTimes(1);
});
});
function createSession(options: { isLive?: boolean } = {}) {
let currentDuration = Number.POSITIVE_INFINITY;
const duration = jest.fn((value?: number) => {
if (value !== undefined) {
currentDuration = value;
}
return currentDuration;
});
const emitPlaybackIssue = jest.fn();
const session = new VjsMpegTsSession({
player: () => ({ duration }),
isLive: () => options.isLive ?? true,
emitPlaybackIssue,
});
return { duration, emitPlaybackIssue, session };
}
function createMpegTsPlayer() {
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
return {
attachMediaElement: jest.fn(),
load: jest.fn(),
play: jest.fn(),
pause: jest.fn(),
unload: jest.fn(),
detachMediaElement: jest.fn(),
destroy: jest.fn(),
on: jest.fn((event: string, listener: (...args: unknown[]) => void) => {
const eventListeners =
listeners.get(event) ?? new Set<(...args: unknown[]) => void>();
eventListeners.add(listener);
listeners.set(event, eventListeners);
}),
off: jest.fn(
(event: string, listener: (...args: unknown[]) => void) => {
listeners.get(event)?.delete(listener);
}
),
emit: (event: string, ...args: unknown[]) => {
for (const listener of listeners.get(event) ?? []) {
listener(...args);
}
},
};
}
function createTimeRanges(ranges: Array<[number, number]>): TimeRanges {
return {
length: ranges.length,
start: (index: number) => ranges[index][0],
end: (index: number) => ranges[index][1],
} as TimeRanges;
}
@@ -0,0 +1,166 @@
import mpegts from 'mpegts.js';
import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyMpegTsPlaybackIssue,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
interface VjsDurationPlayer {
duration(value?: number): unknown;
}
export interface VjsMpegTsSessionConfig {
player: () => VjsDurationPlayer;
isLive: () => boolean;
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void;
}
const VOD_DURATION_EVENTS = [
'durationchange',
'loadedmetadata',
'progress',
'timeupdate',
'error',
] as const;
export class VjsMpegTsSession {
private engine: mpegts.Player | null = null;
private video: HTMLVideoElement | null = null;
private errorListener: ((...args: unknown[]) => void) | null = null;
constructor(private readonly config: VjsMpegTsSessionConfig) {}
isSupportedSource(url?: string): boolean {
if (!url) {
return false;
}
const extension = getPlaybackMediaExtensionFromUrl(url);
return (extension === 'ts' || !extension) && mpegts.isSupported();
}
start(url: string, video: HTMLVideoElement): void {
this.destroy();
const isLive = this.config.isLive();
const engine = mpegts.createPlayer({
type: 'mpegts',
isLive,
url,
});
this.engine = engine;
this.video = video;
this.errorListener = (
type: unknown,
details: unknown,
info: unknown
) => {
this.syncDuration();
this.config.emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
{
type: typeof type === 'string' ? type : undefined,
details:
typeof details === 'string' ? details : undefined,
info,
},
createPlaybackSourceMetadata({
url,
mimeType: 'video/mp2t',
player: InlinePlaybackPlayer.VideoJs,
})
)
);
};
engine.attachMediaElement(video);
if (!isLive) {
this.bindDurationEvents(video);
}
engine.on(mpegts.Events.ERROR, this.errorListener);
engine.load();
const playResult = engine.play();
if (playResult) {
void playResult.catch(() => undefined);
}
}
syncDuration(): void {
if (this.config.isLive() || !this.video) {
return;
}
const duration =
this.readLastFinitePositiveEnd(this.video.seekable) ??
this.readLastFinitePositiveEnd(this.video.buffered);
if (!duration) {
return;
}
const player = this.config.player();
if (player.duration() !== duration) {
player.duration(duration);
}
}
destroy(): void {
const video = this.video;
if (video) {
for (const eventName of VOD_DURATION_EVENTS) {
video.removeEventListener(eventName, this.scheduleDurationSync);
}
}
this.video = null;
const engine = this.engine;
if (!engine) {
this.errorListener = null;
return;
}
if (this.errorListener) {
engine.off(mpegts.Events.ERROR, this.errorListener);
}
this.errorListener = null;
engine.pause();
engine.unload();
engine.detachMediaElement();
engine.destroy();
this.engine = null;
}
private readonly scheduleDurationSync = () => {
this.syncDuration();
this.queueMicrotask(() => this.syncDuration());
};
private bindDurationEvents(video: HTMLVideoElement): void {
for (const eventName of VOD_DURATION_EVENTS) {
video.addEventListener(eventName, this.scheduleDurationSync);
}
this.syncDuration();
}
private readLastFinitePositiveEnd(ranges: TimeRanges): number | null {
for (let index = ranges.length - 1; index >= 0; index -= 1) {
try {
const end = ranges.end(index);
if (Number.isFinite(end) && end > 0) {
return end;
}
} catch {
continue;
}
}
return null;
}
private queueMicrotask(callback: () => void): void {
if (typeof queueMicrotask === 'function') {
queueMicrotask(callback);
return;
}
void Promise.resolve().then(callback);
}
}
@@ -0,0 +1,194 @@
import type { WebVideoControlsOptions } from '../player-controls/web-video-controls.adapter';
import type {
VideoJsAudioTrack,
VideoJsAudioTrackList,
VideoJsPlayer,
VideoJsTextTrack,
VideoJsTextTrackList,
} from './vjs-player.types';
import { VjsPlayerControlsBridge } from './vjs-player-controls.bridge';
describe('VjsPlayerControlsBridge', () => {
it('attaches the current Tech video with live, duration, and track accessors', () => {
const audioTracks = createTrackList<VideoJsAudioTrack>([
{ label: 'English', enabled: true },
{ label: 'Deutsch', enabled: false },
]);
const textTracks = createTrackList<VideoJsTextTrack>([
{ label: 'English CC', kind: 'captions', mode: 'showing' },
]);
const player = createPlayer(audioTracks, textTracks, 91);
const adapter = createAdapter();
const bridge = new VjsPlayerControlsBridge({
player,
adapter: adapter.value,
isLive: () => false,
showCaptions: () => true,
});
const video = document.createElement('video');
bridge.attach(video);
bridge.setSource();
expect(adapter.attach).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),
})
);
expect(adapter.options?.isLive?.()).toBe(false);
expect(adapter.options?.getDuration?.()).toBe(91);
expect(adapter.options?.getAudioTracks?.()).toEqual([
{ id: 0, label: 'English', selected: true },
{ id: 1, label: 'Deutsch', selected: false },
]);
expect(adapter.options?.getSubtitleTracks?.()).toEqual([
{ id: 0, label: 'English CC', selected: true },
]);
adapter.options?.setAudioTrack?.(1);
adapter.options?.setSubtitleTrack?.(-1);
expect(audioTracks[0].enabled).toBe(false);
expect(audioTracks[1].enabled).toBe(true);
expect(textTracks[0].mode).toBe('disabled');
});
it('reattaches only when Video.js replaces the Tech video', () => {
const adapter = createAdapter();
const bridge = new VjsPlayerControlsBridge({
player: createPlayer(),
adapter: adapter.value,
isLive: () => true,
showCaptions: () => false,
});
const firstVideo = document.createElement('video');
const secondVideo = document.createElement('video');
bridge.attach(firstVideo);
bridge.rebind(firstVideo);
bridge.rebind(secondVideo);
expect(adapter.attach).toHaveBeenCalledTimes(2);
expect(adapter.attach).toHaveBeenNthCalledWith(
1,
firstVideo,
expect.any(Object)
);
expect(adapter.attach).toHaveBeenNthCalledWith(
2,
secondVideo,
expect.any(Object)
);
});
it('suppresses and restores the engine-selected subtitle from input changes', () => {
let showCaptions = false;
const textTracks = createTrackList<VideoJsTextTrack>([
{ label: 'English CC', kind: 'captions', mode: 'showing' },
]);
const adapter = createAdapter();
const bridge = new VjsPlayerControlsBridge({
player: createPlayer(undefined, textTracks),
adapter: adapter.value,
isLive: () => false,
showCaptions: () => showCaptions,
});
bridge.attach(document.createElement('video'));
bridge.setSource();
expect(textTracks[0].mode).toBe('disabled');
showCaptions = true;
bridge.refreshInputs();
expect(textTracks[0].mode).toBe('showing');
expect(adapter.refresh).toHaveBeenCalled();
});
it('clears source-owned tracks and tears down idempotently', () => {
const audioTracks = createTrackList<VideoJsAudioTrack>([
{ label: 'English', enabled: true },
{ label: 'Deutsch', enabled: false },
]);
const adapter = createAdapter();
const bridge = new VjsPlayerControlsBridge({
player: createPlayer(audioTracks),
adapter: adapter.value,
isLive: () => true,
showCaptions: () => false,
});
bridge.attach(document.createElement('video'));
bridge.setSource();
expect(adapter.options?.getAudioTracks?.()).toHaveLength(2);
bridge.clearSource();
expect(adapter.options?.getAudioTracks?.()).toEqual([]);
bridge.destroy();
bridge.destroy();
bridge.attach(document.createElement('video'));
expect(adapter.detach).toHaveBeenCalledTimes(1);
expect(adapter.attach).toHaveBeenCalledTimes(1);
});
});
function createAdapter() {
const adapter: {
options?: WebVideoControlsOptions;
attach: jest.Mock;
detach: jest.Mock;
refresh: jest.Mock;
} = {
attach: jest.fn((_video, options: WebVideoControlsOptions) => {
adapter.options = options;
}),
detach: jest.fn(),
refresh: jest.fn(),
};
return {
get options() {
return adapter.options;
},
attach: adapter.attach,
detach: adapter.detach,
refresh: adapter.refresh,
value: adapter as unknown as import('../player-controls/web-video-controls.adapter').WebVideoControlsAdapter,
};
}
function createPlayer(
audioTracks = createTrackList<VideoJsAudioTrack>([]),
textTracks = createTrackList<VideoJsTextTrack>([]),
duration = 0
): VideoJsPlayer {
return {
audioTracks: jest.fn(() => audioTracks),
textTracks: jest.fn(() => textTracks),
duration: jest.fn(() => duration),
getChild: jest.fn(() => null),
tech: jest.fn(() => null),
} as unknown as VideoJsPlayer;
}
function createTrackList<TTrack extends object>(
tracks: TTrack[]
): (TTrack[] & {
addEventListener: jest.Mock;
removeEventListener: jest.Mock;
}) &
(TTrack extends VideoJsAudioTrack
? VideoJsAudioTrackList
: VideoJsTextTrackList) {
return Object.assign(tracks, {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}) as never;
}
@@ -0,0 +1,122 @@
import type { WebVideoControlsOptions } from '../player-controls/web-video-controls.adapter';
import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
import { VjsAudioTracks } from './vjs-audio-tracks';
import type { VideoJsPlayer } from './vjs-player.types';
import { VjsTextTracks } from './vjs-text-tracks';
export interface VjsPlayerControlsBridgeConfig {
player: VideoJsPlayer;
adapter: WebVideoControlsAdapter;
isLive: () => boolean;
showCaptions: () => boolean;
}
export class VjsPlayerControlsBridge {
private readonly audioTracks: VjsAudioTracks;
private readonly textTracks: VjsTextTracks;
private video: HTMLVideoElement | null = null;
private sourceActive = false;
private destroyed = false;
private readonly adapterOptions: WebVideoControlsOptions = {
isLive: () => this.config.isLive(),
getDuration: () => this.readDuration(),
getAudioTracks: () => this.audioTracks.getAudioTracks(),
setAudioTrack: (id) => this.audioTracks.setAudioTrack(id),
getSubtitleTracks: () => this.textTracks.getSubtitleTracks(),
setSubtitleTrack: (id) => this.textTracks.setSubtitleTrack(id),
};
constructor(private readonly config: VjsPlayerControlsBridgeConfig) {
const refresh = () => this.config.adapter.refresh();
this.audioTracks = new VjsAudioTracks({
player: config.player,
refresh,
});
this.textTracks = new VjsTextTracks({
player: config.player,
showCaptions: config.showCaptions,
refresh,
});
}
attach(video: HTMLVideoElement): void {
if (this.destroyed || this.video === video) {
return;
}
this.video = video;
this.config.adapter.attach(video, this.adapterOptions);
if (this.sourceActive) {
this.bindTrackLists();
}
}
rebind(video: HTMLVideoElement): void {
this.attach(video);
}
setSource(): void {
if (this.destroyed) {
return;
}
this.audioTracks.resetSource();
this.textTracks.resetSource();
this.sourceActive = true;
this.bindTrackLists();
this.config.adapter.refresh();
}
refreshInputs(): void {
if (this.destroyed) {
return;
}
if (this.sourceActive) {
this.bindTrackLists();
this.textTracks.refreshInputs();
}
this.config.adapter.refresh();
}
clearSource(): void {
if (this.destroyed) {
return;
}
this.audioTracks.clear();
this.textTracks.clear();
this.sourceActive = false;
this.config.adapter.refresh();
}
destroy(): void {
if (this.destroyed) {
return;
}
this.audioTracks.clear();
this.textTracks.clear();
if (this.video) {
this.config.adapter.detach();
}
this.video = null;
this.sourceActive = false;
this.destroyed = true;
}
private bindTrackLists(): void {
this.audioTracks.bind();
this.textTracks.bind();
}
private readDuration(): number {
try {
const duration = this.config.player.duration();
return typeof duration === 'number' ? duration : NaN;
} catch {
return NaN;
}
}
}
@@ -0,0 +1,110 @@
import { VjsPlayerResetCoordinator } from './vjs-player-reset-coordinator';
import type { VideoJsPlayer } from './vjs-player.types';
describe('VjsPlayerResetCoordinator', () => {
it('waits for pause before reset and coalesces later requests', () => {
const harness = createHarness(false);
const coordinator = createCoordinator(harness);
coordinator.requestReset();
coordinator.requestReset();
expect(harness.pause).toHaveBeenCalledTimes(1);
expect(harness.reset).not.toHaveBeenCalled();
harness.paused = true;
coordinator.handlePause();
expect(harness.reset).toHaveBeenCalledTimes(1);
coordinator.requestReset();
expect(harness.reset).toHaveBeenCalledTimes(1);
});
it('captures the latest engine volume after an asynchronous pause', () => {
const harness = createHarness(false, 0.3);
const coordinator = createCoordinator(harness);
coordinator.requestReset();
harness.player.volume(0.8);
harness.paused = true;
coordinator.handlePause();
expect(coordinator.handlePlayerReset()).toBe(0.8);
});
it('snapshots actual engine volume and suppresses reset-generated changes', () => {
const harness = createHarness(true, 0.3);
const coordinator = createCoordinator(harness, 0.8);
coordinator.requestReset();
expect(coordinator.shouldSuppressVolumeChange()).toBe(true);
expect(coordinator.handlePlayerReset()).toBe(0.3);
expect(coordinator.shouldSuppressVolumeChange()).toBe(false);
});
it('cancels a pending reset but reports an in-flight reset to the caller', () => {
const waitingHarness = createHarness(false);
const waiting = createCoordinator(waitingHarness);
waiting.requestReset();
expect(waiting.cancelPendingReset()).toBe(false);
waitingHarness.paused = true;
waiting.handlePause();
expect(waitingHarness.reset).not.toHaveBeenCalled();
const activeHarness = createHarness(true);
const active = createCoordinator(activeHarness);
active.requestReset();
expect(active.cancelPendingReset()).toBe(true);
});
it('tracks whether the current Tech already has its desired source', () => {
const coordinator = createCoordinator(createHarness(true));
expect(coordinator.canApplyReadySource()).toBe(true);
coordinator.markSourceApplied();
expect(coordinator.canApplyReadySource()).toBe(false);
coordinator.handlePlayerReset();
expect(coordinator.canApplyReadySource()).toBe(true);
});
});
function createCoordinator(
harness: ReturnType<typeof createHarness>,
fallbackVolume = 1
): VjsPlayerResetCoordinator {
return new VjsPlayerResetCoordinator({
player: () => harness.player,
fallbackVolume: () => fallbackVolume,
queueTask: (callback) => harness.tasks.push(callback),
reportError: harness.reportError,
});
}
function createHarness(paused: boolean, initialVolume = 0.5) {
let volume = initialVolume;
const harness = {
paused,
tasks: [] as Array<() => void>,
pause: jest.fn(),
reset: jest.fn(),
reportError: jest.fn(),
player: null as unknown as VideoJsPlayer,
};
harness.player = {
pause: harness.pause,
paused: jest.fn(() => harness.paused),
reset: harness.reset,
volume: jest.fn((value?: number) => {
if (value !== undefined) {
volume = value;
}
return volume;
}),
} as unknown as VideoJsPlayer;
return harness;
}
@@ -0,0 +1,141 @@
import type { VideoJsPlayer } from './vjs-player.types';
type ResetPlayer = Pick<VideoJsPlayer, 'pause' | 'paused' | 'reset' | 'volume'>;
export interface VjsPlayerResetCoordinatorConfig {
player: () => ResetPlayer;
fallbackVolume: () => number;
queueTask: (callback: () => void) => void;
reportError: (error: unknown) => void;
}
export class VjsPlayerResetCoordinator {
private requested = false;
private inFlight = false;
private sourceApplied = false;
private volumeSnapshot: number | null = null;
private destroyed = false;
constructor(private readonly config: VjsPlayerResetCoordinatorConfig) {}
requestReset(): void {
if (this.destroyed || this.requested || this.inFlight) {
return;
}
this.requested = true;
try {
this.config.player().pause();
} catch {
// The pause event/current paused state below remains authoritative.
}
if (this.isPlayerPaused()) {
this.performReset();
return;
}
this.config.queueTask(() => {
if (this.requested && !this.inFlight && this.isPlayerPaused()) {
this.performReset();
}
});
}
handlePause(): void {
if (this.requested && !this.inFlight) {
this.performReset();
}
}
cancelPendingReset(): boolean {
this.requested = false;
if (!this.inFlight) {
this.volumeSnapshot = null;
}
return this.inFlight;
}
handlePlayerReset(): number {
this.requested = false;
this.inFlight = false;
this.sourceApplied = false;
const volume = this.volumeSnapshot ?? this.readFallbackVolume();
this.volumeSnapshot = null;
return volume;
}
shouldSuppressVolumeChange(): boolean {
return this.inFlight;
}
canApplyReadySource(): boolean {
return !this.requested && !this.inFlight && !this.sourceApplied;
}
clearSourceApplied(): void {
this.sourceApplied = false;
}
markSourceApplied(): void {
this.sourceApplied = true;
}
destroy(): void {
this.destroyed = true;
this.requested = false;
this.inFlight = false;
this.sourceApplied = false;
this.volumeSnapshot = null;
}
private performReset(): void {
if (
this.destroyed ||
!this.requested ||
this.inFlight ||
!this.isPlayerPaused()
) {
return;
}
this.captureVolume();
this.requested = false;
this.inFlight = true;
try {
this.config.player().reset();
} catch (error: unknown) {
this.inFlight = false;
this.volumeSnapshot = null;
this.config.reportError(error);
}
}
private captureVolume(): void {
if (this.volumeSnapshot !== null) {
return;
}
try {
const volume = this.config.player().volume();
if (typeof volume === 'number' && Number.isFinite(volume)) {
this.volumeSnapshot = Math.max(0, Math.min(1, volume));
return;
}
} catch {
// Fall through to the validated parent fallback.
}
this.volumeSnapshot = this.readFallbackVolume();
}
private readFallbackVolume(): number {
const volume = this.config.fallbackVolume();
return Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
}
private isPlayerPaused(): boolean {
try {
return this.config.player().paused() !== false;
} catch {
return false;
}
}
}
@@ -0,0 +1,199 @@
import {
createVjsPlayerOptions,
exitOwnedVjsFullscreen,
hasVjsMpegTsModeChanged,
hasVjsPlaybackInputChanged,
initializeVjsPlugins,
shouldChangeVjsSource,
} from './vjs-player-setup';
import type { VideoJsPlayer } from './vjs-player.types';
describe('Video.js player setup', () => {
it('preserves legacy options when shared controls are disabled', () => {
const options = {
sources: [{ src: 'https://example.test/movie.mp4' }],
userActions: { hotkeys: true },
spatialNavigation: { enabled: true },
};
expect(createVjsPlayerOptions(options, false, false)).toEqual({
...options,
autoplay: true,
});
});
it('removes duplicate Video.js interaction ownership for shared controls', () => {
expect(
createVjsPlayerOptions(
{
sources: [{ src: 'https://example.test/live.ts' }],
userActions: { custom: true, hotkeys: true },
spatialNavigation: { custom: true, enabled: true },
},
true,
true
)
).toEqual(
expect.objectContaining({
sources: [],
autoplay: false,
controls: false,
userActions: {
custom: true,
click: false,
doubleClick: false,
hotkeys: false,
},
spatialNavigation: {
custom: true,
enabled: false,
},
})
);
});
it('detects source identity and explicit reload changes', () => {
const source = {
src: 'https://example.test/movie.mp4',
type: 'video/mp4',
};
expect(
hasVjsPlaybackInputChanged(
{ reloadToken: 1 },
{ reloadToken: 1 },
source,
{ ...source }
)
).toBe(false);
expect(
hasVjsPlaybackInputChanged(
{ reloadToken: 1 },
{ reloadToken: 2 },
source,
source
)
).toBe(true);
expect(
hasVjsPlaybackInputChanged({}, {}, source, {
...source,
type: 'application/octet-stream',
})
).toBe(true);
});
it('detects only semantic live-mode changes for raw MPEG-TS', () => {
expect(
hasVjsMpegTsModeChanged(
{ isLive: undefined },
{ isLive: true },
true
)
).toBe(false);
expect(
hasVjsMpegTsModeChanged({ isLive: true }, { isLive: false }, true)
).toBe(true);
expect(
hasVjsMpegTsModeChanged({ isLive: true }, { isLive: false }, false)
).toBe(false);
});
it('combines source identity and raw MPEG-TS live-mode changes', () => {
const isMpegTsSource = jest.fn(() => true);
expect(
shouldChangeVjsSource(
{
isLive: true,
sources: [{ src: 'https://example.test/live.ts' }],
},
{
isLive: false,
sources: [{ src: 'https://example.test/live.ts' }],
},
isMpegTsSource
)
).toBe(true);
expect(isMpegTsSource).toHaveBeenCalledWith(
'https://example.test/live.ts'
);
});
it('initializes optional plugins independently', () => {
const qualitySelectorHls = jest.fn(() => {
throw new Error('quality failed');
});
const aspectRatioPanel = jest.fn();
const warn = jest.spyOn(console, 'warn').mockImplementation();
initializeVjsPlugins({
qualitySelectorHls,
aspectRatioPanel,
} as unknown as VideoJsPlayer);
expect(qualitySelectorHls).toHaveBeenCalledWith({
displayCurrentQuality: true,
});
expect(aspectRatioPanel).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
'qualitySelectorHls plugin failed to initialize:',
expect.any(Error)
);
warn.mockRestore();
});
it('exits fullscreen only when the shared Video.js shell owns it', () => {
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
document,
'fullscreenElement'
);
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
);
const surface = document.createElement('div');
let fullscreenElement: Element | null = document.createElement('div');
const exitFullscreen = jest.fn().mockResolvedValue(undefined);
const reportError = jest.fn();
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
try {
exitOwnedVjsFullscreen(true, surface, reportError);
expect(exitFullscreen).not.toHaveBeenCalled();
fullscreenElement = surface;
exitOwnedVjsFullscreen(false, surface, reportError);
expect(exitFullscreen).not.toHaveBeenCalled();
exitOwnedVjsFullscreen(true, surface, reportError);
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(reportError).not.toHaveBeenCalled();
} finally {
restoreDocumentProperty(
'fullscreenElement',
fullscreenElementDescriptor
);
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
}
});
});
function restoreDocumentProperty(
property: 'exitFullscreen' | 'fullscreenElement',
descriptor: PropertyDescriptor | undefined
): void {
if (descriptor) {
Object.defineProperty(document, property, descriptor);
return;
}
delete (document as unknown as Record<string, unknown>)[property];
}
@@ -0,0 +1,118 @@
import type {
VideoJsPlayer,
VideoPlayerOptions,
VideoPlayerSource,
} from './vjs-player.types';
export function createVjsPlayerOptions(
options: VideoPlayerOptions,
isMpegTs: boolean,
sharedControls: boolean
): VideoPlayerOptions {
const baseOptions = isMpegTs
? { ...options, sources: [], autoplay: false }
: { ...options, autoplay: true };
if (!sharedControls) {
return baseOptions;
}
return {
...baseOptions,
controls: false,
userActions: {
...options.userActions,
click: false,
doubleClick: false,
hotkeys: false,
},
spatialNavigation: {
...options.spatialNavigation,
enabled: false,
},
};
}
export function hasVjsPlaybackInputChanged(
previousOptions: VideoPlayerOptions,
currentOptions: VideoPlayerOptions,
previousSource: VideoPlayerSource | undefined,
currentSource: VideoPlayerSource | undefined
): boolean {
return (
previousOptions.reloadToken !== currentOptions.reloadToken ||
previousSource?.src !== currentSource?.src ||
previousSource?.type !== currentSource?.type
);
}
export function hasVjsMpegTsModeChanged(
previousOptions: VideoPlayerOptions,
currentOptions: VideoPlayerOptions,
isMpegTsSource: boolean
): boolean {
return (
isMpegTsSource &&
(previousOptions.isLive !== false) !== (currentOptions.isLive !== false)
);
}
export function shouldChangeVjsSource(
previousOptions: VideoPlayerOptions,
currentOptions: VideoPlayerOptions,
isMpegTsSource: (url?: string) => boolean
): boolean {
const previousSource = previousOptions.sources?.[0];
const currentSource = currentOptions.sources?.[0];
return (
hasVjsPlaybackInputChanged(
previousOptions,
currentOptions,
previousSource,
currentSource
) ||
hasVjsMpegTsModeChanged(
previousOptions,
currentOptions,
isMpegTsSource(currentSource?.src)
)
);
}
export function initializeVjsPlugins(player: VideoJsPlayer): void {
try {
player.qualitySelectorHls?.({ displayCurrentQuality: true });
} catch (error) {
console.warn('qualitySelectorHls plugin failed to initialize:', error);
}
try {
player.aspectRatioPanel?.();
} catch (error) {
console.warn('aspectRatioPanel plugin failed to initialize:', error);
}
}
export function exitOwnedVjsFullscreen(
sharedControls: boolean,
surface: HTMLElement | undefined,
reportError: (error: unknown) => void
): void {
if (
!sharedControls ||
document.fullscreenElement !== surface ||
typeof document.exitFullscreen !== 'function'
) {
return;
}
try {
void Promise.resolve(document.exitFullscreen()).catch(reportError);
} catch (error: unknown) {
reportError(error);
}
}
export function queueVjsTask(callback: () => void): void {
if (typeof queueMicrotask === 'function') {
queueMicrotask(callback);
} else {
void Promise.resolve().then(callback);
}
}
@@ -1,17 +1,28 @@
<div class="vjs-player-shell">
<div #playerRoot class="vjs-player-shell">
<video
#target
class="video-js"
controls
[controls]="!sharedControls"
muted
playsinline
preload="none"
data-setup='{"liveui": true, "muted": false}'
></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,359 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls/web-player-controls.flag';
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
import type { VideoJsPlayer } from './vjs-player.types';
const videoJsMock = jest.fn();
const mpegTsCreatePlayerMock = jest.fn();
const mpegTsIsSupportedMock = jest.fn(() => true);
jest.unstable_mockModule('video.js', () => ({
default: videoJsMock,
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
jest.unstable_mockModule('mpegts.js', () => ({
default: {
Events: { ERROR: 'error' },
createPlayer: mpegTsCreatePlayerMock,
isSupported: mpegTsIsSupportedMock,
},
}));
describe('VjsPlayerComponent reset lifecycle', () => {
let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent;
let fixture: ComponentFixture<VjsPlayerComponentInstance>;
let playerHarness: ReturnType<typeof createVideoJsPlayerHarness>;
beforeAll(async () => {
({ VjsPlayerComponent } = await import('./vjs-player.component'));
});
beforeEach(async () => {
localStorage.removeItem('volume');
playerHarness = createVideoJsPlayerHarness();
videoJsMock
.mockReset()
.mockImplementation(
(_target: Element, _options: unknown, ready: () => void) => {
playerHarness.ready = ready;
return playerHarness.player;
}
);
mpegTsCreatePlayerMock.mockReset().mockImplementation(() => {
const engine = createMpegTsPlayer();
playerHarness.mpegTsPlayers.push(engine);
return engine;
});
mpegTsIsSupportedMock.mockReset().mockReturnValue(true);
await TestBed.configureTestingModule({
imports: [VjsPlayerComponent, TranslateModule.forRoot()],
providers: [
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true },
],
}).compileComponents();
fixture = TestBed.createComponent(VjsPlayerComponent);
});
afterEach(() => {
fixture.destroy();
});
it('coalesces reset-required changes and attaches only the latest MPEG-TS source', () => {
const initialVideo = document.createElement('video');
playerHarness.currentVideo = initialVideo;
fixture.componentRef.setInput('options', {
isLive: true,
sources: [
{
src: 'https://example.test/live/one.ts',
type: 'video/mp2t',
},
],
});
fixture.detectChanges();
playerHarness.ready();
expect(playerHarness.mpegTsPlayers).toHaveLength(1);
expect(
playerHarness.mpegTsPlayers[0].attachMediaElement
).toHaveBeenCalledWith(initialVideo);
fixture.componentRef.setInput('options', {
isLive: true,
sources: [
{
src: 'https://example.test/live/two.ts',
type: 'video/mp2t',
},
],
});
fixture.detectChanges();
fixture.componentRef.setInput('options', {
isLive: true,
sources: [
{
src: 'https://example.test/live/three.ts',
type: 'video/mp2t',
},
],
});
fixture.detectChanges();
expect(playerHarness.reset).toHaveBeenCalledTimes(1);
expect(playerHarness.mpegTsPlayers).toHaveLength(1);
expect(playerHarness.mpegTsPlayers[0].destroy).toHaveBeenCalledTimes(1);
const latestVideo = document.createElement('video');
playerHarness.currentVideo = latestVideo;
playerHarness.emit('playerreset');
expect(playerHarness.mpegTsPlayers).toHaveLength(2);
expect(mpegTsCreatePlayerMock).toHaveBeenLastCalledWith({
type: 'mpegts',
isLive: true,
url: 'https://example.test/live/three.ts',
});
expect(
playerHarness.mpegTsPlayers[1].attachMediaElement
).toHaveBeenCalledWith(latestVideo);
});
it('never calls Video.js reset until active playback has paused', () => {
fixture.componentRef.setInput('options', {
sources: [{ src: 'https://example.test/movie.mp4' }],
});
fixture.detectChanges();
playerHarness.ready();
playerHarness.paused = false;
playerHarness.pauseCompletesImmediately = false;
fixture.componentRef.setInput('options', {
isLive: true,
sources: [
{
src: 'https://example.test/live/stream.ts',
type: 'video/mp2t',
},
],
});
fixture.detectChanges();
expect(playerHarness.pause).toHaveBeenCalledTimes(1);
expect(playerHarness.reset).not.toHaveBeenCalled();
playerHarness.paused = true;
playerHarness.emit('pause');
expect(playerHarness.reset).toHaveBeenCalledTimes(1);
});
it('restores a volume input that changes while reset waits for pause', () => {
localStorage.setItem('volume', '0.3');
fixture.componentRef.setInput('options', {
sources: [{ src: 'https://example.test/movie.mp4' }],
});
fixture.componentRef.setInput('volume', 0.3);
fixture.detectChanges();
playerHarness.ready();
playerHarness.paused = false;
playerHarness.pauseCompletesImmediately = false;
fixture.componentRef.setInput('options', { sources: [] });
fixture.detectChanges();
fixture.componentRef.setInput('volume', 0.8);
fixture.detectChanges();
playerHarness.emit('volumechange');
playerHarness.paused = true;
playerHarness.emit('pause');
playerHarness.volume(1);
playerHarness.emit('volumechange');
playerHarness.currentVideo = document.createElement('video');
playerHarness.emit('playerreset');
expect(localStorage.getItem('volume')).toBe('0.8');
expect(playerHarness.volume).toHaveBeenLastCalledWith(0.8);
});
it('restores the latest normal source after an already-started reset', () => {
fixture.componentRef.setInput('options', {
sources: [{ src: 'https://example.test/movie.mp4' }],
});
fixture.detectChanges();
playerHarness.ready();
fixture.componentRef.setInput('options', {
isLive: true,
sources: [
{
src: 'https://example.test/live/stream.ts',
type: 'video/mp2t',
},
],
});
fixture.detectChanges();
const latestSource = {
src: 'https://example.test/latest.mp4',
type: 'video/mp4',
};
fixture.componentRef.setInput('options', {
isLive: false,
sources: [latestSource],
});
fixture.detectChanges();
expect(playerHarness.src).not.toHaveBeenCalledWith(latestSource);
playerHarness.currentVideo = document.createElement('video');
playerHarness.emit('playerreset');
expect(playerHarness.src).toHaveBeenLastCalledWith(latestSource);
expect(playerHarness.mpegTsPlayers).toHaveLength(0);
});
it('does not start MPEG-TS twice when playerreset precedes Video.js ready', () => {
fixture.componentRef.setInput('options', {
isLive: true,
sources: [
{
src: 'https://example.test/live/one.ts',
type: 'video/mp2t',
},
],
});
fixture.detectChanges();
fixture.componentRef.setInput('options', {
isLive: true,
sources: [
{
src: 'https://example.test/live/two.ts',
type: 'video/mp2t',
},
],
});
fixture.detectChanges();
playerHarness.currentVideo = document.createElement('video');
playerHarness.emit('playerreset');
expect(playerHarness.mpegTsPlayers).toHaveLength(1);
playerHarness.ready();
expect(playerHarness.mpegTsPlayers).toHaveLength(1);
expect(mpegTsCreatePlayerMock).toHaveBeenLastCalledWith({
type: 'mpegts',
isLive: true,
url: 'https://example.test/live/two.ts',
});
});
it('restarts active MPEG-TS when authoritative live metadata changes', () => {
const source = {
src: 'https://example.test/live/stream.ts',
type: 'video/mp2t',
};
fixture.componentRef.setInput('options', {
isLive: true,
sources: [source],
});
fixture.detectChanges();
playerHarness.ready();
fixture.componentRef.setInput('options', {
isLive: false,
sources: [{ ...source }],
});
fixture.detectChanges();
expect(playerHarness.reset).toHaveBeenCalledTimes(1);
expect(playerHarness.mpegTsPlayers[0].destroy).toHaveBeenCalledTimes(1);
playerHarness.currentVideo = document.createElement('video');
playerHarness.emit('playerreset');
expect(mpegTsCreatePlayerMock).toHaveBeenLastCalledWith({
type: 'mpegts',
isLive: false,
url: source.src,
});
expect(playerHarness.mpegTsPlayers).toHaveLength(2);
});
});
function createVideoJsPlayerHarness() {
const listeners = new Map<string, Set<() => void>>();
let volume = 0.5;
const harness = {
currentVideo: document.createElement('video'),
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
mpegTsPlayers: [] as ReturnType<typeof createMpegTsPlayer>[],
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
harness.paused = true;
}
}),
reset: jest.fn(),
src: jest.fn(),
volume: jest.fn((value?: number) => {
if (value !== undefined) {
volume = value;
}
return volume;
}),
emit(event: string) {
for (const listener of listeners.get(event) ?? []) {
listener();
}
},
player: null as unknown as VideoJsPlayer,
};
harness.player = {
audioTracks: jest.fn(() => null),
textTracks: jest.fn(() => null),
currentTime: jest.fn(() => 0),
duration: jest.fn(() => 0),
error: jest.fn(() => null),
getChild: jest.fn(() => null),
on: jest.fn((event: string, listener: () => void) => {
const eventListeners =
listeners.get(event) ?? new Set<() => void>();
eventListeners.add(listener);
listeners.set(event, eventListeners);
}),
off: jest.fn((event: string, listener: () => void) => {
listeners.get(event)?.delete(listener);
}),
pause: harness.pause,
paused: jest.fn(() => harness.paused),
reset: harness.reset,
src: harness.src,
tech: jest.fn(() => ({ el: () => harness.currentVideo })),
volume: harness.volume,
dispose: jest.fn(),
qualitySelectorHls: jest.fn(),
aspectRatioPanel: jest.fn(),
} as unknown as VideoJsPlayer;
return harness;
}
function createMpegTsPlayer() {
return {
attachMediaElement: jest.fn(),
load: jest.fn(),
play: jest.fn(),
pause: jest.fn(),
unload: jest.fn(),
detachMediaElement: jest.fn(),
destroy: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
}
@@ -0,0 +1,258 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls/web-player-controls.flag';
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
import type { VideoJsPlayer } from './vjs-player.types';
const videoJsMock = jest.fn();
const mpegTsCreatePlayerMock = jest.fn();
const mpegTsIsSupportedMock = jest.fn(() => true);
jest.unstable_mockModule('video.js', () => ({
default: videoJsMock,
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
jest.unstable_mockModule('mpegts.js', () => ({
default: {
Events: { ERROR: 'error' },
createPlayer: mpegTsCreatePlayerMock,
isSupported: mpegTsIsSupportedMock,
},
}));
describe('VjsPlayerComponent shared controls', () => {
let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent;
let fixture: ComponentFixture<VjsPlayerComponentInstance>;
let playerHarness: ReturnType<typeof createVideoJsPlayerHarness>;
beforeAll(async () => {
({ VjsPlayerComponent } = await import('./vjs-player.component'));
});
beforeEach(async () => {
playerHarness = createVideoJsPlayerHarness();
videoJsMock
.mockReset()
.mockImplementation(
(_target: Element, _options: unknown, ready: () => void) => {
playerHarness.ready = ready;
return playerHarness.player;
}
);
mpegTsCreatePlayerMock.mockReset().mockImplementation(() => {
const engine = createMpegTsPlayer();
playerHarness.mpegTsPlayers.push(engine);
return engine;
});
mpegTsIsSupportedMock.mockReset().mockReturnValue(true);
await TestBed.configureTestingModule({
imports: [VjsPlayerComponent, TranslateModule.forRoot()],
providers: [
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true },
],
}).compileComponents();
fixture = TestBed.createComponent(VjsPlayerComponent);
});
afterEach(() => {
fixture.destroy();
});
it('disables every Video.js interaction surface and renders shared controls', () => {
fixture.componentRef.setInput('options', {
sources: [{ src: 'https://example.test/movie.mp4' }],
userActions: { custom: true, hotkeys: true },
spatialNavigation: { custom: true, enabled: true },
});
fixture.detectChanges();
playerHarness.ready();
fixture.detectChanges();
expect(videoJsMock).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({
controls: false,
userActions: {
custom: true,
click: false,
doubleClick: false,
hotkeys: false,
},
spatialNavigation: {
custom: true,
enabled: false,
},
}),
expect.any(Function)
);
expect(
fixture.debugElement.query(By.directive(PlayerControlsComponent))
).not.toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="series-playback-previous-episode"]'
)
).toBeNull();
expect(fixture.nativeElement.querySelector('video').controls).toBe(
false
);
});
it('gates the shared surface and shortcuts with interaction availability', () => {
fixture.componentRef.setInput('options', {
sources: [{ src: 'https://example.test/movie.mp4' }],
});
fixture.componentRef.setInput('interactionEnabled', false);
fixture.detectChanges();
playerHarness.ready();
fixture.detectChanges();
const controls = fixture.debugElement.query(
By.directive(PlayerControlsComponent)
).componentInstance as PlayerControlsComponent;
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', () => {
fixture.componentRef.setInput('options', {
sources: [{ src: 'https://example.test/movie.mp4' }],
});
fixture.detectChanges();
playerHarness.ready();
fixture.detectChanges();
const shell = fixture.debugElement.query(By.css('.vjs-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);
}
});
});
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];
}
function createVideoJsPlayerHarness() {
const listeners = new Map<string, Set<() => void>>();
const harness = {
currentVideo: document.createElement('video'),
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
mpegTsPlayers: [] as ReturnType<typeof createMpegTsPlayer>[],
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
harness.paused = true;
}
}),
reset: jest.fn(),
src: jest.fn(),
emit(event: string) {
for (const listener of listeners.get(event) ?? []) {
listener();
}
},
player: null as unknown as VideoJsPlayer,
};
harness.player = {
audioTracks: jest.fn(() => null),
textTracks: jest.fn(() => null),
currentTime: jest.fn(() => 0),
duration: jest.fn(() => 0),
error: jest.fn(() => null),
getChild: jest.fn(() => null),
on: jest.fn((event: string, listener: () => void) => {
const eventListeners =
listeners.get(event) ?? new Set<() => void>();
eventListeners.add(listener);
listeners.set(event, eventListeners);
}),
off: jest.fn((event: string, listener: () => void) => {
listeners.get(event)?.delete(listener);
}),
pause: harness.pause,
paused: jest.fn(() => harness.paused),
reset: harness.reset,
src: harness.src,
tech: jest.fn(() => ({ el: () => harness.currentVideo })),
volume: jest.fn(() => 0.5),
dispose: jest.fn(),
qualitySelectorHls: jest.fn(),
aspectRatioPanel: jest.fn(),
} as unknown as VideoJsPlayer;
return harness;
}
function createMpegTsPlayer() {
return {
attachMediaElement: jest.fn(),
load: jest.fn(),
play: jest.fn(),
pause: jest.fn(),
unload: jest.fn(),
detachMediaElement: jest.fn(),
destroy: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
}
@@ -1,27 +1,23 @@
import { SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
import type { VideoJsPlayer } from './vjs-player.types';
const videoJsMock = jest.fn();
const mpegTsCreatePlayerMock = jest.fn();
const mpegTsIsSupportedMock = jest.fn(() => false);
jest.unstable_mockModule('video.js', () => ({
default: videoJsMock,
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
jest.unstable_mockModule('mpegts.js', () => ({
default: {
createPlayer: mpegTsCreatePlayerMock,
Events: { ERROR: 'error' },
createPlayer: jest.fn(),
isSupported: mpegTsIsSupportedMock,
Events: {
ERROR: 'error',
},
},
}));
@@ -29,375 +25,205 @@ describe('VjsPlayerComponent', () => {
let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent;
let fixture: ComponentFixture<VjsPlayerComponentInstance>;
let component: VjsPlayerComponentInstance;
let player: VjsPlayerComponentInstance['player'];
type SignalApiShape = {
options: () => unknown;
volume: () => number;
startTime: () => number;
timeUpdate: { emit: (event: unknown) => void };
playbackIssue: {
emit: (event: unknown) => void;
subscribe: (callback: (event: unknown) => void) => {
unsubscribe: () => void;
};
};
};
let harness: ReturnType<typeof createPlayerHarness>;
beforeAll(async () => {
({ VjsPlayerComponent } = await import('./vjs-player.component'));
});
beforeEach(async () => {
mpegTsCreatePlayerMock.mockReset();
mpegTsCreatePlayerMock.mockReturnValue({
attachMediaElement: jest.fn(),
pause: jest.fn(),
unload: jest.fn(),
detachMediaElement: jest.fn(),
destroy: jest.fn(),
on: jest.fn(),
load: jest.fn(),
play: jest.fn(),
});
videoJsMock.mockReset();
harness = createPlayerHarness();
videoJsMock
.mockReset()
.mockImplementation(
(_target: Element, _options: unknown, ready: () => void) => {
harness.ready = ready;
return harness.player;
}
);
mpegTsIsSupportedMock.mockReset().mockReturnValue(false);
await TestBed.configureTestingModule({
imports: [VjsPlayerComponent],
}).compileComponents();
fixture = TestBed.createComponent(VjsPlayerComponent);
component = fixture.componentInstance;
player = {
error: jest.fn(),
src: jest.fn(),
reset: jest.fn(),
volume: jest.fn(),
dispose: jest.fn(),
} as unknown as VjsPlayerComponentInstance['player'];
component.player = player;
});
afterEach(() => {
fixture.destroy();
if (!fixture.componentRef.hostView.destroyed) {
fixture.destroy();
}
localStorage.removeItem('volume');
});
it('uses signal-based inputs and outputs', () => {
const signalComponent = component as unknown as SignalApiShape;
expect(typeof signalComponent.options).toBe('function');
expect(signalComponent.volume()).toBe(1);
expect(signalComponent.startTime()).toBe(0);
expect(typeof signalComponent.timeUpdate.emit).toBe('function');
expect(typeof signalComponent.playbackIssue.emit).toBe('function');
expect(typeof component.options).toBe('function');
expect(component.volume()).toBe(1);
expect(component.startTime()).toBe(0);
expect(component.interactionEnabled()).toBe(true);
expect(component.showCaptions()).toBe(false);
expect(typeof component.timeUpdate.emit).toBe('function');
expect(typeof component.playbackIssue.emit).toBe('function');
});
it('does not reset VideoJS when options change without changing the source', () => {
const previousOptions = {
sources: [
{
src: 'https://example.com/live/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
};
const currentOptions = {
sources: [
{
src: 'https://example.com/live/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
};
component.ngOnChanges({
options: new SimpleChange(previousOptions, currentOptions, false),
it('preserves legacy Video.js options and controls while the flag is off', () => {
render({
sources: [{ src: 'https://example.test/movie.mp4' }],
userActions: { hotkeys: true },
spatialNavigation: { enabled: true },
});
expect(player.src).not.toHaveBeenCalled();
});
it('updates VideoJS when options change to a different source', () => {
const previousOptions = {
sources: [
{
src: 'https://example.com/live/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
};
const currentOptions = {
sources: [
{
src: 'https://example.com/live/other-playlist.m3u8',
type: 'application/x-mpegURL',
},
],
};
component.ngOnChanges({
options: new SimpleChange(previousOptions, currentOptions, false),
});
expect(player.src).toHaveBeenCalledWith(currentOptions.sources[0]);
});
it('updates volume when options change without changing the source', () => {
const previousOptions = {
sources: [
{
src: 'https://example.com/live/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
};
const currentOptions = {
sources: [
{
src: 'https://example.com/live/playlist.m3u8',
type: 'application/x-mpegURL',
},
],
};
component.ngOnChanges({
options: new SimpleChange(previousOptions, currentOptions, false),
volume: new SimpleChange(0.5, 0.75, false),
});
expect(player.src).not.toHaveBeenCalled();
expect(player.volume).toHaveBeenCalledWith(0.75);
});
it('does not treat query-declared HLS streams as MPEG-TS sources', () => {
mpegTsIsSupportedMock.mockReturnValue(true);
const isMpegTsSource = (
component as unknown as {
isMpegTsSource: (url?: string) => boolean;
}
).isMpegTsSource.bind(component);
expect(
isMpegTsSource('https://example.com/play?extension=m3u8&token=x')
).toBe(false);
expect(
isMpegTsSource('https://example.com/live.php?extension=ts')
).toBe(true);
expect(isMpegTsSource('https://example.com/live.php?stream=123')).toBe(
expect(videoJsMock).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({
autoplay: true,
userActions: { hotkeys: true },
spatialNavigation: { enabled: true },
}),
expect.any(Function)
);
expect(fixture.nativeElement.querySelector('video').controls).toBe(
true
);
expect(
fixture.nativeElement.querySelector('app-player-controls')
).toBeNull();
});
it('emits a playback issue when VideoJS reports an unsupported source', () => {
const issues: unknown[] = [];
const videoElement = document.createElement('video');
const testComponent = component as unknown as SignalApiShape & {
options: () => {
sources: Array<{ src: string; type: string }>;
};
target: () => { nativeElement: HTMLVideoElement };
handleVideoJsPlaybackError: () => void;
it('does not reload Video.js when options keep the same source', () => {
const source = {
src: 'https://example.test/live/playlist.m3u8',
type: 'application/x-mpegURL',
};
testComponent.options = () => ({
render({ sources: [source] });
harness.src.mockClear();
fixture.componentRef.setInput('options', {
sources: [{ ...source }],
});
fixture.detectChanges();
expect(harness.src).not.toHaveBeenCalled();
expect(harness.reset).not.toHaveBeenCalled();
});
it('updates Video.js directly for a different normal source', () => {
render({
sources: [{ src: 'https://example.test/live/one.m3u8' }],
});
const nextSource = {
src: 'https://example.test/live/two.m3u8',
type: 'application/x-mpegURL',
};
fixture.componentRef.setInput('options', {
sources: [nextSource],
});
fixture.detectChanges();
expect(harness.src).toHaveBeenCalledWith(nextSource);
expect(harness.reset).not.toHaveBeenCalled();
});
it('resets Video.js when the source is cleared', () => {
render({
sources: [{ src: 'https://example.test/live/one.m3u8' }],
});
fixture.componentRef.setInput('options', { sources: [] });
fixture.detectChanges();
expect(harness.reset).toHaveBeenCalledTimes(1);
expect(harness.src).not.toHaveBeenCalled();
});
it('updates volume without reloading an unchanged source', () => {
render({
sources: [{ src: 'https://example.test/live/playlist.m3u8' }],
});
harness.volume.mockClear();
harness.src.mockClear();
fixture.componentRef.setInput('volume', 0.75);
fixture.detectChanges();
expect(harness.volume).toHaveBeenCalledWith(0.75);
expect(harness.src).not.toHaveBeenCalled();
});
it('does not persist reset volume and restores the current engine volume', () => {
localStorage.setItem('volume', '0.35');
render({
sources: [{ src: 'https://example.test/live/playlist.m3u8' }],
});
harness.volume(0.35);
harness.volume.mockClear();
fixture.componentRef.setInput('options', { sources: [] });
fixture.detectChanges();
harness.volume(1);
harness.emit('volumechange');
expect(localStorage.getItem('volume')).toBe('0.35');
harness.currentVideo = document.createElement('video');
harness.emit('playerreset');
expect(harness.volume).toHaveBeenLastCalledWith(0.35);
});
it('emits a diagnostic for a Video.js unsupported-source error', () => {
const issues: Array<PlaybackDiagnostic | null> = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
render({
sources: [
{
src: 'https://example.com/archive/movie.mkv',
src: 'https://example.test/archive/movie.mkv',
type: 'video/matroska',
},
],
});
testComponent.target = () => ({ nativeElement: videoElement });
jest.mocked(player.error).mockReturnValue({
harness.currentError = {
code: 4,
message: 'No compatible source was found',
});
};
const subscription = component.playbackIssue.subscribe((issue) =>
issues.push(issue)
);
testComponent.handleVideoJsPlaybackError();
subscription.unsubscribe();
harness.emit('error');
expect(issues).toEqual([
expect(issues.at(-1)).toEqual(
expect.objectContaining({
code: 'unsupported-container',
source: 'native',
sourceUrl: 'https://example.com/archive/movie.mkv',
sourceUrl: 'https://example.test/archive/movie.mkv',
externalFallbackRecommended: true,
}),
]);
})
);
});
it('tears down mpegts playback when options clear the source', () => {
const mpegtsPlayer = {
pause: jest.fn(),
unload: jest.fn(),
detachMediaElement: jest.fn(),
destroy: jest.fn(),
};
const componentInternals = component as unknown as {
mpegtsPlayer: typeof mpegtsPlayer | null;
};
componentInternals.mpegtsPlayer = mpegtsPlayer;
const previousOptions = {
sources: [
{
src: 'https://example.com/live/stream.ts',
type: 'video/mp2t',
},
],
};
component.ngOnChanges({
options: new SimpleChange(previousOptions, { sources: [] }, false),
});
expect(mpegtsPlayer.pause).toHaveBeenCalled();
expect(mpegtsPlayer.unload).toHaveBeenCalled();
expect(mpegtsPlayer.detachMediaElement).toHaveBeenCalled();
expect(mpegtsPlayer.destroy).toHaveBeenCalled();
expect(componentInternals.mpegtsPlayer).toBeNull();
expect(player.reset).toHaveBeenCalled();
expect(player.src).not.toHaveBeenCalled();
});
it('passes the non-live option to mpegts.js for VOD MPEG-TS playback', () => {
const videoElement = document.createElement('video');
const testComponent = component as unknown as {
options: () => {
isLive: boolean;
sources: Array<{ src: string; type: string }>;
};
player: {
tech: () => { el: () => HTMLVideoElement };
dispose: () => void;
};
initMpegTs: (url: string) => void;
};
testComponent.options = () => ({
isLive: false,
sources: [
{
src: 'https://example.com/movie/123.ts',
type: 'video/mp2t',
},
],
});
testComponent.player = {
tech: () => ({ el: () => videoElement }),
dispose: jest.fn(),
};
testComponent.initMpegTs('https://example.com/movie/123.ts');
expect(mpegTsCreatePlayerMock).toHaveBeenCalledWith({
type: 'mpegts',
isLive: false,
url: 'https://example.com/movie/123.ts',
});
});
it('normalizes VideoJS duration for non-live MPEG-TS VOD when MSE reports infinity', () => {
const videoElement = document.createElement('video');
Object.defineProperty(videoElement, 'seekable', {
value: createTimeRanges([[0, 164.072]]),
});
Object.defineProperty(videoElement, 'buffered', {
value: createTimeRanges([]),
});
const testComponent = component as unknown as {
options: () => {
isLive: boolean;
sources: Array<{ src: string; type: string }>;
};
player: {
duration: (duration: number) => void;
dispose: () => void;
};
mpegTsVodDurationTarget: HTMLVideoElement;
syncMpegTsVodDuration: () => void;
};
testComponent.options = () => ({
isLive: false,
sources: [
{
src: 'https://example.com/movie/123.ts',
type: 'video/mp2t',
},
],
});
testComponent.player = {
duration: jest.fn(),
dispose: jest.fn(),
};
testComponent.mpegTsVodDurationTarget = videoElement;
testComponent.syncMpegTsVodDuration();
expect(testComponent.player.duration).toHaveBeenCalledWith(164.072);
});
it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => {
it('rebinds native ended handling after playerreset', () => {
const events: string[] = [];
videoJsMock.mockReturnValue(createVideoJsPlayerMock());
fixture.componentRef.setInput('options', {
sources: [
{
src: 'https://example.com/series/s01e02.mp4',
type: 'video/mp4',
},
],
component.playbackEnded.subscribe(() => events.push('ended'));
render({
sources: [{ src: 'https://example.test/series/one.mp4' }],
});
(
component as unknown as {
playbackEnded: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).playbackEnded.subscribe(() => events.push('ended'));
const oldVideo = harness.currentVideo;
fixture.componentRef.setInput('options', { sources: [] });
fixture.detectChanges();
fixture.nativeElement
.querySelector('video')
.dispatchEvent(new Event('ended'));
component.ngOnChanges({
options: new SimpleChange(
{
sources: [
{
src: 'https://example.com/series/s01e02.mp4',
type: 'video/mp4',
},
],
},
{
sources: [
{
src: 'https://example.com/series/s01e03.mp4',
type: 'video/mp4',
},
],
},
false
),
});
fixture.destroy();
const replacementVideo = document.createElement('video');
harness.currentVideo = replacementVideo;
harness.emit('playerreset');
oldVideo.dispatchEvent(new Event('ended'));
replacementVideo.dispatchEvent(new Event('ended'));
expect(events).toEqual(['ended']);
});
it('hides series navigation controls when series navigation is absent', () => {
videoJsMock.mockReturnValue(createVideoJsPlayerMock());
fixture.componentRef.setInput('options', {
sources: [
{
src: 'https://example.com/movie.mp4',
type: 'video/mp4',
},
],
it('hides legacy series navigation when metadata is absent', () => {
render({
sources: [{ src: 'https://example.test/movie.mp4' }],
});
fixture.detectChanges();
expect(
fixture.debugElement.query(
By.css('[data-test-id="series-playback-previous-episode"]')
@@ -410,82 +236,97 @@ describe('VjsPlayerComponent', () => {
).toBeNull();
});
it('renders series navigation controls with boundary disabled state', () => {
it('renders legacy series navigation with boundary state and outputs', () => {
const events: string[] = [];
videoJsMock.mockReturnValue(createVideoJsPlayerMock());
fixture.componentRef.setInput('options', {
sources: [
{
src: 'https://example.com/series/s01e01.mp4',
type: 'video/mp4',
},
],
});
component.previousEpisodeRequested.subscribe(() =>
events.push('previous')
);
component.nextEpisodeRequested.subscribe(() => events.push('next'));
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: false,
canNext: true,
autoplayEnabled: true,
});
(
component as unknown as {
previousEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
nextEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).previousEpisodeRequested.subscribe(() => events.push('previous'));
(
component as unknown as {
nextEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).nextEpisodeRequested.subscribe(() => events.push('next'));
render({
sources: [{ src: 'https://example.test/series/one.mp4' }],
});
fixture.detectChanges();
const previousButton = fixture.debugElement.query(
const previous = fixture.debugElement.query(
By.css('[data-test-id="series-playback-previous-episode"]')
);
const nextButton = fixture.debugElement.query(
const next = fixture.debugElement.query(
By.css('[data-test-id="series-playback-next-episode"]')
);
expect(previousButton).not.toBeNull();
expect(previousButton.nativeElement.disabled).toBe(true);
expect(nextButton).not.toBeNull();
expect(nextButton.nativeElement.disabled).toBe(false);
previousButton.nativeElement.click();
nextButton.nativeElement.click();
expect(previous.nativeElement.disabled).toBe(true);
expect(next.nativeElement.disabled).toBe(false);
previous.nativeElement.click();
next.nativeElement.click();
expect(events).toEqual(['next']);
});
function render(options: Record<string, unknown>): void {
fixture.componentRef.setInput('options', options);
fixture.detectChanges();
harness.ready();
fixture.detectChanges();
}
});
function createVideoJsPlayerMock(): VjsPlayerComponentInstance['player'] {
return {
audioTracks: jest.fn(() => null),
currentTime: jest.fn(() => 0),
duration: jest.fn(() => 0),
error: jest.fn(() => null),
getChild: jest.fn(() => null),
on: jest.fn(),
function createPlayerHarness() {
const listeners = new Map<string, Set<() => void>>();
let volumeValue = 0.5;
const harness = {
currentVideo: document.createElement('video'),
currentError: null as { code?: number; message?: string } | null,
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
harness.paused = true;
}
}),
reset: jest.fn(),
src: jest.fn(),
tech: jest.fn(() => ({ el: () => null })),
volume: jest.fn(),
volume: jest.fn((value?: number) => {
if (value !== undefined) {
volumeValue = value;
}
return volumeValue;
}),
emit(event: string) {
for (const listener of listeners.get(event) ?? []) {
listener();
}
},
player: null as unknown as VideoJsPlayer,
};
harness.player = {
audioTracks: jest.fn(() => null),
textTracks: jest.fn(() => null),
currentTime: jest.fn(() => 0),
duration: jest.fn(() => 0),
error: jest.fn(() => harness.currentError),
getChild: jest.fn(() => null),
on: jest.fn((event: string, listener: () => void) => {
const eventListeners =
listeners.get(event) ?? new Set<() => void>();
eventListeners.add(listener);
listeners.set(event, eventListeners);
}),
off: jest.fn((event: string, listener: () => void) => {
listeners.get(event)?.delete(listener);
}),
pause: harness.pause,
paused: jest.fn(() => harness.paused),
reset: harness.reset,
src: harness.src,
tech: jest.fn(() => ({ el: () => harness.currentVideo })),
volume: harness.volume,
dispose: jest.fn(),
qualitySelectorHls: jest.fn(),
aspectRatioPanel: jest.fn(),
} as unknown as VjsPlayerComponentInstance['player'];
}
function createTimeRanges(ranges: Array<[number, number]>): TimeRanges {
return {
length: ranges.length,
start: (index: number) => ranges[index][0],
end: (index: number) => ranges[index][1],
} as TimeRanges;
} as unknown as VideoJsPlayer;
return harness;
}
@@ -6,92 +6,52 @@ import {
OnInit,
SimpleChanges,
ViewEncapsulation,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import '@yangkghjh/videojs-aspect-ratio-panel';
import mpegts from 'mpegts.js';
import { createDevLogger } from '@iptvnator/shared/interfaces';
import videoJs from 'video.js';
import 'videojs-contrib-quality-levels';
import 'videojs-quality-selector-hls';
import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
classifyMpegTsPlaybackIssue,
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
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';
/**
* This component contains the implementation of video player that is based on video.js library
*/
type VideoPlayerSource = {
src: string;
type?: string;
};
type VideoPlayerOptions = Record<string, unknown> & {
autoplay?: boolean;
isLive?: boolean;
reloadToken?: number;
sources?: VideoPlayerSource[];
};
type VideoJsAudioTrack = {
label?: string;
language?: string;
enabled?: boolean;
kind?: string;
};
type VideoJsAudioTrackList = {
length: number;
[index: number]: VideoJsAudioTrack;
addEventListener: (
type: string,
listener: EventListenerOrEventListenerObject
) => void;
};
type VideoJsTech = {
el?: () => Element | null;
vhs?: {
playlists?: {
main?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
master?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
};
};
};
type VideoJsControlChild = {
getChild?: (name: string) => VideoJsControlChild | null;
addChild?: (
name: string,
options?: Record<string, unknown>
) => VideoJsControlChild | null;
show?: () => void;
update?: () => void;
};
type VideoJsPlayer = Omit<
ReturnType<typeof videoJs>,
'audioTracks' | 'tech' | 'getChild'
> & {
qualitySelectorHls?: (options?: {
displayCurrentQuality?: boolean;
}) => void;
aspectRatioPanel?: () => void;
audioTracks: () => VideoJsAudioTrackList | null;
tech: (options?: unknown) => VideoJsTech | null;
getChild: (name: string) => VideoJsControlChild | null;
error: () => { code?: number; message?: string } | null;
};
import {
VjsAudioTracks,
logVjsAudioTracks,
setupVjsAudioTrackMenu,
} from './vjs-audio-tracks';
import { VjsMpegTsSession } from './vjs-mpegts-session';
import { VjsPlayerControlsBridge } from './vjs-player-controls.bridge';
import {
createVjsPlayerOptions,
exitOwnedVjsFullscreen,
initializeVjsPlugins,
queueVjsTask,
shouldChangeVjsSource,
} from './vjs-player-setup';
import { VjsPlayerResetCoordinator } from './vjs-player-reset-coordinator';
import {
type VideoJsPlayer,
type VideoPlayerOptions,
type VideoPlayerSource,
getVideoJsTechVideo,
} from './vjs-player.types';
import { VjsVideoElementSession } from './vjs-video-element-session';
const debugVjsPlayer = createDevLogger('VjsPlayer');
@@ -100,29 +60,24 @@ const debugVjsPlayer = createDevLogger('VjsPlayer');
templateUrl: './vjs-player.component.html',
styleUrls: ['./vjs-player.component.scss'],
encapsulation: ViewEncapsulation.None,
imports: [SeriesPlaybackNavigationControlsComponent],
imports: [
PlayerControlsComponent,
SeriesPlaybackNavigationControlsComponent,
],
providers: [WebVideoControlsAdapter],
standalone: true,
})
export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
/** DOM-element reference */
readonly target = viewChild.required<ElementRef<Element>>('target');
/** Options of VideoJs player */
readonly target =
viewChild.required<ElementRef<HTMLVideoElement>>('target');
readonly playerRoot = viewChild<ElementRef<HTMLElement>>('playerRoot');
readonly options = input.required<VideoPlayerOptions>();
/** VideoJs object */
player!: VideoJsPlayer;
/** mpegts.js player for raw MPEG-TS streams */
private mpegtsPlayer: mpegts.Player | null = null;
private mpegTsVodDurationTarget: HTMLVideoElement | null = null;
private readonly mpegTsVodDurationEvents = [
'durationchange',
'loadedmetadata',
'progress',
'timeupdate',
'error',
] as const;
readonly volume = input(1);
readonly startTime = input(0);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
@@ -132,141 +87,90 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
private readonly clearPlaybackIssue = () => {
this.playbackIssue.emit(null);
};
private readonly handlePlaybackEnded = () => {
this.playbackEnded.emit();
};
private readonly scheduleMpegTsVodDurationSync = () => {
this.syncMpegTsVodDuration();
this.queueDurationSync(() => this.syncMpegTsVodDuration());
};
readonly sharedControls = inject(WEB_PLAYER_SHARED_CONTROLS);
readonly controlsAdapter = inject(WebVideoControlsAdapter);
player!: VideoJsPlayer;
private readonly seriesNavigationSignal =
signal<SeriesPlaybackNavigation | null>(null);
private readonly videoSession = new VjsVideoElementSession({
clearPlaybackIssue: () => this.playbackIssue.emit(null),
emitPlaybackEnded: () => this.playbackEnded.emit(),
});
private readonly mpegTsSession = new VjsMpegTsSession({
player: () => this.player,
isLive: () => this.options().isLive !== false,
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
});
private readonly resetCoordinator = new VjsPlayerResetCoordinator({
player: () => this.player,
fallbackVolume: () => this.volume(),
queueTask: queueVjsTask,
reportError: (error) =>
debugVjsPlayer('Failed to reset Video.js player:', error),
});
private controlsBridge: VjsPlayerControlsBridge | null = null;
private legacyAudioTracks: VjsAudioTracks | null = null;
private desiredSource: VideoPlayerSource | null = null;
private readyHandled = false;
private destroyed = false;
/**
* Instantiate Video.js on component init
*/
ngOnInit(): void {
const source = this.options().sources?.[0];
const isMpegTs = this.isMpegTsSource(source?.src);
const targetVideo = this.target().nativeElement as HTMLVideoElement;
targetVideo.addEventListener('loadeddata', this.clearPlaybackIssue);
targetVideo.addEventListener('playing', this.clearPlaybackIssue);
targetVideo.addEventListener('ended', this.handlePlaybackEnded);
// For raw MPEG-TS streams, init Video.js without a source (UI/controls only)
const vjsOptions = isMpegTs
? { ...this.options(), sources: [], autoplay: false }
: { ...this.options(), autoplay: true };
this.player = videoJs(this.target().nativeElement, vjsOptions, () => {
debugVjsPlayer(
'Setting VideoJS player initial volume to:',
this.volume()
);
this.player.volume(this.volume());
this.player.on('loadedmetadata', () => {
if (this.startTime() > 0) {
this.player.currentTime(this.startTime());
}
this.playbackIssue.emit(null);
this.logAudioTracks();
this.setupAudioTrackMenu();
this.desiredSource = this.options().sources?.[0] ?? null;
this.seriesNavigationSignal.set(this.seriesNavigation());
if (this.sharedControls) {
this.controlsAdapter.setContext({
seriesNavigation: this.seriesNavigationSignal,
});
this.player.on('error', () => {
this.handleVideoJsPlaybackError();
});
// Audio tracks may be added after loadedmetadata (e.g. HLS alternate audio)
const audioTracks = this.player.audioTracks();
if (audioTracks) {
audioTracks.addEventListener('addtrack', () => {
debugVjsPlayer(
'[AudioTrack] addtrack event fired, total tracks:',
audioTracks.length
);
this.logAudioTracks();
this.setupAudioTrackMenu();
});
audioTracks.addEventListener('removetrack', () => {
debugVjsPlayer(
'[AudioTrack] removetrack event fired, total tracks:',
audioTracks.length
);
this.logAudioTracks();
this.setupAudioTrackMenu();
});
audioTracks.addEventListener('change', () => {
this.logAudioTracks();
});
}
this.player.on('volumechange', () => {
const currentVolume = this.player.volume() ?? this.volume();
localStorage.setItem('volume', currentVolume.toString());
});
this.player.on('timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime() ?? 0,
duration: this.player.duration() ?? 0,
});
});
// Attach mpegts.js after Video.js is ready
if (isMpegTs && source) {
this.initMpegTs(source.src);
}
}) as unknown as VideoJsPlayer;
try {
if (typeof this.player.qualitySelectorHls === 'function') {
this.player.qualitySelectorHls({
displayCurrentQuality: true,
});
}
} catch (e) {
console.warn('qualitySelectorHls plugin failed to initialize:', e);
}
try {
if (typeof this.player.aspectRatioPanel === 'function') {
this.player.aspectRatioPanel();
}
} catch (e) {
console.warn('aspectRatioPanel plugin failed to initialize:', e);
}
const vjsOptions = createVjsPlayerOptions(
this.options(),
this.mpegTsSession.isSupportedSource(this.desiredSource?.src),
this.sharedControls
);
this.player = videoJs(
this.target().nativeElement,
vjsOptions,
this.handlePlayerReady
) as unknown as VideoJsPlayer;
this.bindPlayerEvents();
initializeVjsPlugins(this.player);
}
/**
* Replaces the url source of the player with the changed source url
* @param changes contains changed channel object
*/
ngOnChanges(changes: SimpleChanges): void {
if (changes['options']?.previousValue) {
const previousSource =
changes['options'].previousValue.sources?.[0];
const newSource = changes['options'].currentValue.sources?.[0];
if (changes['seriesNavigation']) {
this.seriesNavigationSignal.set(this.seriesNavigation());
}
if (changes['options']?.previousValue && this.player) {
const previousOptions = changes['options']
.previousValue as VideoPlayerOptions;
const currentOptions = changes['options']
.currentValue as VideoPlayerOptions;
if (
this.hasPlaybackInputChanged(
changes['options'].previousValue,
changes['options'].currentValue,
previousSource,
newSource
shouldChangeVjsSource(previousOptions, currentOptions, (url) =>
this.mpegTsSession.isSupportedSource(url)
)
) {
this.playbackIssue.emit(null);
this.destroyMpegTs();
if (!newSource) {
this.player.reset();
} else if (this.isMpegTsSource(newSource.src)) {
this.player.reset();
this.initMpegTs(newSource.src);
} else {
this.player.src(newSource);
}
this.changeSource(currentOptions.sources?.[0] ?? null);
} else {
this.controlsBridge?.refreshInputs();
}
}
if (changes['showCaptions']) {
this.controlsBridge?.refreshInputs();
}
if (changes['interactionEnabled']?.currentValue === false) {
exitOwnedVjsFullscreen(
this.sharedControls,
this.playerRoot()?.nativeElement,
(error) =>
debugVjsPlayer(
'Failed to exit Video.js player fullscreen:',
error
)
);
}
if (changes['volume']?.currentValue !== undefined && this.player) {
debugVjsPlayer(
'Setting VideoJS player volume to:',
@@ -276,284 +180,218 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
}
/**
* Removes the players HTML reference on destroy
*/
ngOnDestroy(): void {
this.destroyMpegTs();
this.removeNativePlaybackListeners();
this.destroyed = true;
this.resetCoordinator.destroy();
this.controlsBridge?.destroy();
this.controlsBridge = null;
this.legacyAudioTracks?.clear();
this.legacyAudioTracks = null;
this.mpegTsSession.destroy();
this.videoSession.destroy();
if (this.player) {
this.unbindPlayerEvents();
this.player.dispose();
}
}
private isMpegTsSource(url?: string): boolean {
if (!url) return false;
const extension = getPlaybackMediaExtensionFromUrl(url);
return (extension === 'ts' || !extension) && mpegts.isSupported();
}
private hasPlaybackInputChanged(
previousOptions: VideoPlayerOptions,
newOptions: VideoPlayerOptions,
previousSource: VideoPlayerSource | undefined,
newSource: VideoPlayerSource | undefined
): boolean {
return (
previousOptions.reloadToken !== newOptions.reloadToken ||
previousSource?.src !== newSource?.src ||
previousSource?.type !== newSource?.type
);
}
private removeNativePlaybackListeners(): void {
try {
const targetVideo = this.target().nativeElement as HTMLVideoElement;
targetVideo.removeEventListener(
'loadeddata',
this.clearPlaybackIssue
);
targetVideo.removeEventListener('playing', this.clearPlaybackIssue);
targetVideo.removeEventListener('ended', this.handlePlaybackEnded);
} catch {
// Required viewChild can be unavailable when a shallow unit test destroys an unrendered component.
private readonly handlePlayerReady = () => {
if (this.destroyed || this.readyHandled) {
return;
}
}
private initMpegTs(url: string): void {
const videoEl = this.player
.tech({ IWillNotUseThisInPlugins: true })
?.el?.();
if (!videoEl) return;
debugVjsPlayer('Using mpegts.js for TS stream:', url);
const isLive = this.options().isLive !== false;
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
isLive,
url: url,
});
this.mpegtsPlayer.attachMediaElement(videoEl as HTMLVideoElement);
if (!isLive) {
this.attachMpegTsVodDurationNormalization(
videoEl as HTMLVideoElement
);
if (!this.player) {
queueVjsTask(this.handlePlayerReady);
return;
}
this.mpegtsPlayer.on(
mpegts.Events.ERROR,
(type: string, details: string, info: unknown): void => {
this.syncMpegTsVodDuration();
this.playbackIssue.emit(
classifyMpegTsPlaybackIssue(
{
type,
details,
info,
},
this.createSourceMetadata(url, 'video/mp2t')
)
);
}
this.readyHandled = true;
debugVjsPlayer(
'Setting VideoJS player initial volume to:',
this.volume()
);
this.mpegtsPlayer.load();
this.mpegtsPlayer.play();
}
this.player.volume(this.volume());
this.initializeLegacyAudioTracks();
const video = this.bindCurrentTechVideo();
if (video && this.resetCoordinator.canApplyReadySource()) {
this.activateSource(this.desiredSource, video);
}
};
private handleVideoJsPlaybackError(): void {
const source = this.options().sources?.[0];
const targetVideo = this.target().nativeElement as HTMLVideoElement;
const videoJsError =
typeof this.player?.error === 'function'
private readonly handleLoadedMetadata = () => {
this.bindCurrentTechVideo();
if (this.startTime() > 0) {
this.player.currentTime(this.startTime());
}
this.playbackIssue.emit(null);
this.legacyAudioTracks?.bind();
logVjsAudioTracks(this.player);
setupVjsAudioTrackMenu(this.player);
this.controlsBridge?.refreshInputs();
};
private readonly handleVideoJsError = () => {
const source = this.desiredSource;
const video =
this.videoSession.video() ??
getVideoJsTechVideo(this.player) ??
this.target().nativeElement;
const playerError =
typeof this.player.error === 'function'
? this.player.error()
: null;
const nativeError = targetVideo?.error ?? null;
this.syncMpegTsVodDuration();
this.mpegTsSession.syncDuration();
this.playbackIssue.emit(
classifyNativePlaybackIssue(
videoJsError ?? nativeError,
this.createSourceMetadata(
source?.src ?? targetVideo?.currentSrc ?? '',
source?.type
)
playerError ?? video.error,
createPlaybackSourceMetadata({
url: source?.src ?? video.currentSrc ?? '',
mimeType: source?.type,
player: InlinePlaybackPlayer.VideoJs,
})
)
);
};
private readonly handleVolumeChange = () => {
if (this.resetCoordinator.shouldSuppressVolumeChange()) {
return;
}
const currentVolume = this.player.volume() ?? this.volume();
localStorage.setItem('volume', currentVolume.toString());
};
private readonly handleTimeUpdate = () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime() ?? 0,
duration: this.player.duration() ?? 0,
});
};
private readonly handlePlayerReset = () => {
this.player.volume(this.resetCoordinator.handlePlayerReset());
this.controlsBridge?.clearSource();
this.legacyAudioTracks?.clear();
this.mpegTsSession.destroy();
this.restoreDesiredSourceAfterReset(true);
};
private readonly handlePauseForReset = () => {
this.resetCoordinator.handlePause();
};
private changeSource(source: VideoPlayerSource | null): void {
this.playbackIssue.emit(null);
this.controlsBridge?.clearSource();
this.legacyAudioTracks?.clear();
this.mpegTsSession.destroy();
this.desiredSource = source;
this.resetCoordinator.clearSourceApplied();
if (!source || this.mpegTsSession.isSupportedSource(source.src)) {
this.resetCoordinator.requestReset();
return;
}
if (this.resetCoordinator.cancelPendingReset()) {
return;
}
this.player.src(source);
this.controlsBridge?.setSource();
this.resetCoordinator.markSourceApplied();
}
private attachMpegTsVodDurationNormalization(
videoEl: HTMLVideoElement
private restoreDesiredSourceAfterReset(allowRetry: boolean): void {
if (this.destroyed) {
return;
}
const video = this.bindCurrentTechVideo();
if (!video) {
if (allowRetry) {
queueVjsTask(() => this.restoreDesiredSourceAfterReset(false));
}
return;
}
const source = this.desiredSource;
if (!source) {
this.resetCoordinator.markSourceApplied();
return;
}
if (this.mpegTsSession.isSupportedSource(source.src)) {
this.activateSource(source, video);
return;
}
this.player.src(source);
this.controlsBridge?.setSource();
this.legacyAudioTracks?.bind();
this.resetCoordinator.markSourceApplied();
}
private activateSource(
source: VideoPlayerSource | null,
video: HTMLVideoElement
): void {
this.detachMpegTsVodDurationNormalization();
this.mpegTsVodDurationTarget = videoEl;
for (const eventName of this.mpegTsVodDurationEvents) {
videoEl.addEventListener(
eventName,
this.scheduleMpegTsVodDurationSync
);
}
this.syncMpegTsVodDuration();
}
private detachMpegTsVodDurationNormalization(): void {
if (!this.mpegTsVodDurationTarget) {
if (!source) {
this.resetCoordinator.markSourceApplied();
return;
}
for (const eventName of this.mpegTsVodDurationEvents) {
this.mpegTsVodDurationTarget.removeEventListener(
eventName,
this.scheduleMpegTsVodDurationSync
);
this.controlsBridge?.setSource();
this.legacyAudioTracks?.bind();
if (this.mpegTsSession.isSupportedSource(source.src)) {
debugVjsPlayer('Using mpegts.js for TS stream:', source.src);
this.mpegTsSession.start(source.src, video);
}
this.mpegTsVodDurationTarget = null;
this.resetCoordinator.markSourceApplied();
}
private syncMpegTsVodDuration(): void {
if (this.options().isLive !== false || !this.mpegTsVodDurationTarget) {
return;
private bindCurrentTechVideo(): HTMLVideoElement | null {
const video = getVideoJsTechVideo(this.player);
if (!video) {
return null;
}
const duration = this.getFiniteMpegTsVodDuration(
this.mpegTsVodDurationTarget
);
if (!duration) {
return;
}
if (this.player.duration() !== duration) {
this.player.duration(duration);
}
}
private getFiniteMpegTsVodDuration(videoEl: HTMLVideoElement): number {
return (
this.getFiniteTimeRangeEnd(videoEl.seekable) ??
this.getFiniteTimeRangeEnd(videoEl.buffered) ??
0
);
}
private getFiniteTimeRangeEnd(ranges: TimeRanges): number | null {
for (let index = ranges.length - 1; index >= 0; index--) {
try {
const end = ranges.end(index);
if (Number.isFinite(end) && end > 0) {
return end;
}
} catch {
continue;
this.videoSession.bind(video);
if (this.sharedControls) {
if (!this.controlsBridge) {
this.controlsBridge = new VjsPlayerControlsBridge({
player: this.player,
adapter: this.controlsAdapter,
isLive: () => this.options().isLive !== false,
showCaptions: () => this.showCaptions(),
});
this.controlsBridge.attach(video);
} else {
this.controlsBridge.rebind(video);
}
}
return null;
return video;
}
private queueDurationSync(callback: () => void): void {
if (typeof queueMicrotask === 'function') {
queueMicrotask(callback);
private initializeLegacyAudioTracks(): void {
if (this.sharedControls || this.legacyAudioTracks) {
return;
}
void Promise.resolve().then(callback);
}
private createSourceMetadata(url: string, mimeType?: string) {
return createPlaybackSourceMetadata({
url,
mimeType,
player: InlinePlaybackPlayer.VideoJs,
this.legacyAudioTracks = new VjsAudioTracks({
player: this.player,
refresh: () => undefined,
});
this.legacyAudioTracks.bind();
}
private destroyMpegTs(): void {
this.detachMpegTsVodDurationNormalization();
if (this.mpegtsPlayer) {
this.mpegtsPlayer.pause();
this.mpegtsPlayer.unload();
this.mpegtsPlayer.detachMediaElement();
this.mpegtsPlayer.destroy();
this.mpegtsPlayer = null;
}
private bindPlayerEvents(): void {
this.player.on('loadedmetadata', this.handleLoadedMetadata);
this.player.on('error', this.handleVideoJsError);
this.player.on('volumechange', this.handleVolumeChange);
this.player.on('timeupdate', this.handleTimeUpdate);
this.player.on('pause', this.handlePauseForReset);
this.player.on('playerreset', this.handlePlayerReset);
}
/**
* Logs all available audio tracks for debugging.
*/
private logAudioTracks(): void {
const audioTracks = this.player.audioTracks();
debugVjsPlayer(
'[AudioTrack] Audio tracks count:',
audioTracks?.length ?? 0
);
if (!audioTracks) {
return;
}
for (let i = 0; i < audioTracks.length; i++) {
const t = audioTracks[i];
debugVjsPlayer(
`[AudioTrack] Track ${i}: label="${t.label}", language="${t.language}", enabled=${t.enabled}, kind="${t.kind}"`
);
}
// Also check the underlying tech for HLS audio tracks
const tech =
typeof this.player?.tech === 'function'
? this.player.tech({ IWillNotUseThisInPlugins: true })
: null;
const audioMediaGroups =
tech?.vhs?.playlists?.main?.mediaGroups?.AUDIO ??
tech?.vhs?.playlists?.master?.mediaGroups?.AUDIO;
if (audioMediaGroups) {
debugVjsPlayer(
'[AudioTrack] HLS AUDIO media groups:',
JSON.stringify(Object.keys(audioMediaGroups))
);
} else {
debugVjsPlayer(
'[AudioTrack] HLS AUDIO media groups: none found in playlist metadata'
);
}
}
/**
* Sets up the audio track selection menu in the control bar.
* Uses the Video.js audioTracks() API which works with both
* native multi-audio streams and HLS.js alternate audio tracks.
*/
private setupAudioTrackMenu(): void {
const audioTracks = this.player.audioTracks();
debugVjsPlayer(
'[AudioTrack] setupAudioTrackMenu called, tracks:',
audioTracks?.length ?? 0
);
if (!audioTracks || audioTracks.length <= 1) {
debugVjsPlayer(
'[AudioTrack] Skipping menu: need >1 tracks, have',
audioTracks?.length ?? 0
);
debugVjsPlayer(
'[AudioTrack] If VLC/MPV show more tracks, the HLS manifest likely does not expose alternate audio via EXT-X-MEDIA'
);
return;
}
const controlBar = this.player.getChild('controlBar');
if (!controlBar) {
return;
}
let audioButton =
controlBar.getChild?.('audioTrackButton') ??
controlBar.getChild?.('AudioTrackButton');
if (!audioButton && controlBar.addChild) {
audioButton = controlBar.addChild('audioTrackButton', {});
}
if (audioButton) {
audioButton.show?.();
audioButton.update?.();
}
private unbindPlayerEvents(): void {
this.player.off('loadedmetadata', this.handleLoadedMetadata);
this.player.off('error', this.handleVideoJsError);
this.player.off('volumechange', this.handleVolumeChange);
this.player.off('timeupdate', this.handleTimeUpdate);
this.player.off('pause', this.handlePauseForReset);
this.player.off('playerreset', this.handlePlayerReset);
}
}
@@ -0,0 +1,30 @@
import { type VideoJsPlayer, getVideoJsTechVideo } from './vjs-player.types';
describe('getVideoJsTechVideo', () => {
it('returns the current Video.js Tech video element', () => {
const video = document.createElement('video');
const tech = jest.fn(() => ({ el: () => video }));
expect(getVideoJsTechVideo({ tech } as unknown as VideoJsPlayer)).toBe(
video
);
expect(tech).toHaveBeenCalledWith({
IWillNotUseThisInPlugins: true,
});
});
it('rejects non-video Tech elements and transient access failures', () => {
expect(
getVideoJsTechVideo({
tech: () => ({ el: () => document.createElement('div') }),
} as unknown as VideoJsPlayer)
).toBeNull();
expect(
getVideoJsTechVideo({
tech: () => {
throw new Error('Tech unavailable');
},
} as unknown as VideoJsPlayer)
).toBeNull();
});
});
@@ -0,0 +1,98 @@
import type videoJs from 'video.js';
export type VideoPlayerSource = {
src: string;
type?: string;
};
export type VideoPlayerOptions = Record<string, unknown> & {
autoplay?: boolean;
controls?: boolean;
isLive?: boolean;
reloadToken?: number;
sources?: VideoPlayerSource[];
spatialNavigation?: Record<string, unknown> & {
enabled?: boolean;
};
userActions?: Record<string, unknown> & {
click?: boolean;
doubleClick?: boolean;
hotkeys?: boolean;
};
};
export type VideoJsAudioTrack = {
label?: string;
language?: string;
enabled?: boolean;
kind?: string;
};
export type VideoJsTextTrack = {
label?: string;
language?: string;
kind?: string;
mode: TextTrackMode;
};
export type VideoJsTrackList<TTrack> = {
length: number;
[index: number]: TTrack;
addEventListener?: (
type: string,
listener: EventListenerOrEventListenerObject
) => void;
removeEventListener?: (
type: string,
listener: EventListenerOrEventListenerObject
) => void;
};
export type VideoJsAudioTrackList = VideoJsTrackList<VideoJsAudioTrack>;
export type VideoJsTextTrackList = VideoJsTrackList<VideoJsTextTrack>;
export type VideoJsTech = {
el?: () => Element | null;
vhs?: {
playlists?: {
main?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
master?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
};
};
};
export type VideoJsControlChild = {
getChild?: (name: string) => VideoJsControlChild | null;
addChild?: (
name: string,
options?: Record<string, unknown>
) => VideoJsControlChild | null;
show?: () => void;
update?: () => void;
};
export type VideoJsPlayer = Omit<
ReturnType<typeof videoJs>,
'audioTracks' | 'textTracks' | 'tech' | 'getChild' | 'error'
> & {
qualitySelectorHls?: (options?: {
displayCurrentQuality?: boolean;
}) => void;
aspectRatioPanel?: () => void;
audioTracks: () => VideoJsAudioTrackList | null;
textTracks: () => VideoJsTextTrackList | null;
tech: (options?: unknown) => VideoJsTech | null;
getChild: (name: string) => VideoJsControlChild | null;
error: () => { code?: number; message?: string } | null;
};
export function getVideoJsTechVideo(
player: Pick<VideoJsPlayer, 'tech'>
): HTMLVideoElement | null {
try {
const element = player.tech({ IWillNotUseThisInPlugins: true })?.el?.();
return element instanceof HTMLVideoElement ? element : null;
} catch {
return null;
}
}
@@ -0,0 +1,400 @@
import type {
VideoJsPlayer,
VideoJsTextTrack,
VideoJsTextTrackList,
} from './vjs-player.types';
import { VjsTextTracks } from './vjs-text-tracks';
const TEXT_TRACK_EVENTS = [
'addtrack',
'removetrack',
'change',
'labelchange',
] as const;
describe('VjsTextTracks', () => {
it('projects only captions and subtitles with stable per-source IDs', () => {
const english = createTextTrack({
kind: 'captions',
label: 'English CC',
mode: 'showing',
});
const metadata = createTextTrack({
kind: 'metadata',
mode: 'showing',
});
const german = createTextTrack({ kind: 'subtitles', language: 'de' });
const fallback = createTextTrack({ kind: 'captions' });
const { helper, tracks } = createBoundHelper([
english,
metadata,
german,
fallback,
]);
expect(helper.getSubtitleTracks()).toEqual([
{ id: 0, label: 'English CC', selected: true },
{ id: 1, label: 'de', selected: false },
{ id: 2, label: 'Subtitle 3', selected: false },
]);
tracks.remove(english);
expect(helper.getSubtitleTracks()).toEqual([
{ id: 1, label: 'de', selected: false },
{ id: 2, label: 'Subtitle 3', selected: false },
]);
tracks.add(english);
expect(helper.getSubtitleTracks()).toEqual([
{ id: 1, label: 'de', selected: false },
{ id: 2, label: 'Subtitle 3', selected: false },
{ id: 0, label: 'English CC', selected: true },
]);
});
it('selects one valid track and disables every other VHS text track', () => {
const first = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const second = createTextTrack({
kind: 'subtitles',
mode: 'hidden',
});
const third = createTextTrack({
kind: 'captions',
mode: 'disabled',
});
const metadata = createTextTrack({
kind: 'metadata',
mode: 'showing',
});
const { helper, tracks } = createBoundHelper([
first,
second,
third,
metadata,
]);
helper.setSubtitleTrack(1);
expect(first.mode).toBe('disabled');
expect(second.mode).toBe('showing');
expect(third.mode).toBe('disabled');
expect(metadata.mode).toBe('showing');
first.mode = 'showing';
second.mode = 'hidden';
tracks.emit('change');
expect(first.mode).toBe('disabled');
expect(second.mode).toBe('showing');
expect(third.mode).toBe('disabled');
});
it('treats -1 as explicit off and preserves it through later events and preferences', () => {
const first = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const second = createTextTrack({
kind: 'subtitles',
mode: 'hidden',
});
const { helper, tracks, captionPreference } = createBoundHelper([
first,
second,
]);
helper.setSubtitleTrack(-1);
first.mode = 'showing';
second.mode = 'showing';
tracks.emit('labelchange');
captionPreference.value = false;
helper.refreshInputs();
captionPreference.value = true;
helper.refreshInputs();
expect(first.mode).toBe('disabled');
expect(second.mode).toBe('disabled');
const subtitles = helper.getSubtitleTracks();
expect(subtitles.every((track) => !track.selected)).toBe(true);
});
it('ignores invalid, non-integer, and stale track IDs', () => {
const removed = createTextTrack({
kind: 'captions',
mode: 'disabled',
});
const remaining = createTextTrack({
kind: 'subtitles',
mode: 'showing',
});
const { helper, tracks } = createBoundHelper([removed, remaining]);
tracks.remove(removed);
for (const id of [0, 0.5, -2, 8, Number.NaN]) {
helper.setSubtitleTrack(id);
}
expect(remaining.mode).toBe('showing');
});
it('suppresses and restores the engine-selected default with the global preference', () => {
const selected = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const hidden = createTextTrack({
kind: 'subtitles',
mode: 'hidden',
});
const { helper, captionPreference } = createBoundHelper(
[selected, hidden],
false
);
expect(selected.mode).toBe('disabled');
expect(hidden.mode).toBe('disabled');
captionPreference.value = true;
helper.refreshInputs();
expect(selected.mode).toBe('showing');
expect(hidden.mode).toBe('disabled');
});
it('retains a late engine default while global captions remain suppressed', () => {
const first = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const { helper, tracks, captionPreference } = createBoundHelper(
[first],
false
);
const late = createTextTrack({
kind: 'subtitles',
mode: 'showing',
});
tracks.add(late);
captionPreference.value = true;
helper.refreshInputs();
expect(first.mode).toBe('disabled');
expect(late.mode).toBe('showing');
});
it('keeps an explicit selection active through preference and track-list events', () => {
const first = createTextTrack({
kind: 'captions',
mode: 'showing',
});
const second = createTextTrack({
kind: 'subtitles',
mode: 'disabled',
});
const { helper, tracks, captionPreference } = createBoundHelper([
first,
second,
]);
helper.setSubtitleTrack(1);
captionPreference.value = false;
helper.refreshInputs();
first.mode = 'showing';
second.mode = 'hidden';
tracks.emit('addtrack');
captionPreference.value = true;
helper.refreshInputs();
expect(first.mode).toBe('disabled');
expect(second.mode).toBe('showing');
});
it('resets IDs, suppression, and explicit override at a source boundary', () => {
const first = createTextTrack({ kind: 'captions' });
const second = createTextTrack({ kind: 'subtitles' });
const { helper, tracks, captionPreference } = createBoundHelper([
first,
second,
]);
helper.setSubtitleTrack(-1);
captionPreference.value = false;
const replacement = createTextTrack({
kind: 'subtitles',
mode: 'showing',
});
tracks.replaceSilently([replacement]);
helper.resetSource();
expect(helper.getSubtitleTracks()).toEqual([
{ id: 0, label: 'Subtitle 1', selected: false },
]);
expect(replacement.mode).toBe('disabled');
captionPreference.value = true;
helper.refreshInputs();
expect(replacement.mode).toBe('showing');
});
it('refreshes on all Video.js text-track events through one stable callback', () => {
const { helper, tracks, refresh } = createBoundHelper([]);
const registrations = [...tracks.addEventListener.mock.calls];
for (const event of TEXT_TRACK_EVENTS) {
tracks.emit(event);
}
expect(refresh).toHaveBeenCalledTimes(4);
expect(registrations.map(([event]) => event)).toEqual(
TEXT_TRACK_EVENTS
);
const listener = registrations[0][1];
expect(registrations.every(([, entry]) => entry === listener)).toBe(
true
);
helper.clear();
});
it('binds by list identity and removes the exact listeners once', () => {
const firstList = new FakeTextTrackList([]);
const secondList = new FakeTextTrackList([]);
let currentList: VideoJsTextTrackList | null = firstList;
const player = {
textTracks: jest.fn(() => currentList),
} as Pick<VideoJsPlayer, 'textTracks'>;
const helper = new VjsTextTracks({
player,
showCaptions: () => true,
refresh: jest.fn(),
});
helper.bind();
const firstRegistrations = [...firstList.addEventListener.mock.calls];
helper.bind();
expect(firstList.addEventListener).toHaveBeenCalledTimes(4);
expect(firstList.removeEventListener).not.toHaveBeenCalled();
currentList = secondList;
helper.bind();
expect(firstList.removeEventListener).toHaveBeenCalledTimes(4);
for (const [event, listener] of firstRegistrations) {
expect(firstList.removeEventListener).toHaveBeenCalledWith(
event,
listener
);
}
expect(
firstList.removeEventListener.mock.calls.every(
(call) => call.length === 2
)
).toBe(true);
const secondRegistrations = [...secondList.addEventListener.mock.calls];
helper.clear();
helper.clear();
expect(secondList.removeEventListener).toHaveBeenCalledTimes(4);
for (const [event, listener] of secondRegistrations) {
expect(secondList.removeEventListener).toHaveBeenCalledWith(
event,
listener
);
}
});
});
function createBoundHelper(
initialTracks: VideoJsTextTrack[],
showCaptions = true
) {
const tracks = new FakeTextTrackList(initialTracks);
const player = {
textTracks: jest.fn(() => tracks),
} as Pick<VideoJsPlayer, 'textTracks'>;
const captionPreference = { value: showCaptions };
const refresh = jest.fn();
const helper = new VjsTextTracks({
player,
showCaptions: () => captionPreference.value,
refresh,
});
helper.bind();
refresh.mockClear();
return { captionPreference, helper, refresh, tracks };
}
function createTextTrack(
overrides: Partial<VideoJsTextTrack> = {}
): VideoJsTextTrack {
return { kind: 'subtitles', mode: 'disabled', ...overrides };
}
class FakeTextTrackList implements VideoJsTextTrackList {
[index: number]: VideoJsTextTrack;
length = 0;
private tracks: VideoJsTextTrack[] = [];
private readonly listeners = new Map<
string,
Set<EventListenerOrEventListenerObject>
>();
readonly addEventListener = jest.fn(
(type: string, listener: EventListenerOrEventListenerObject) => {
const eventListeners = this.listeners.get(type) ?? new Set();
eventListeners.add(listener);
this.listeners.set(type, eventListeners);
}
);
readonly removeEventListener = jest.fn(
(type: string, listener: EventListenerOrEventListenerObject) => {
this.listeners.get(type)?.delete(listener);
}
);
constructor(initialTracks: VideoJsTextTrack[]) {
this.replaceSilently(initialTracks);
}
add(track: VideoJsTextTrack): void {
this.replaceSilently([...this.tracks, track]);
this.emit('addtrack');
}
remove(track: VideoJsTextTrack): void {
this.replaceSilently(this.tracks.filter((entry) => entry !== track));
this.emit('removetrack');
}
replaceSilently(tracks: VideoJsTextTrack[]): void {
for (let index = 0; index < this.length; index += 1) {
delete this[index];
}
this.tracks = [...tracks];
this.length = tracks.length;
tracks.forEach((track, index) => {
this[index] = track;
});
}
emit(type: string): void {
const event = new Event(type);
for (const listener of this.listeners.get(type) ?? []) {
if (typeof listener === 'function') {
listener(event);
} else {
listener.handleEvent(event);
}
}
}
}
@@ -0,0 +1,234 @@
import type { PlayerTrack } from '../player-controls/player-controls.model';
import type {
VideoJsPlayer,
VideoJsTextTrack,
VideoJsTextTrackList,
} from './vjs-player.types';
const TEXT_TRACK_EVENTS = [
'addtrack',
'removetrack',
'change',
'labelchange',
] as const;
interface VjsSubtitleTrack {
id: number;
track: VideoJsTextTrack;
}
export interface VjsTextTracksConfig {
player: Pick<VideoJsPlayer, 'textTracks'>;
showCaptions: () => boolean;
refresh: () => void;
}
export class VjsTextTracks {
private trackList: VideoJsTextTrackList | null = null;
private refreshListener: EventListener | null = null;
private trackIds = new WeakMap<VideoJsTextTrack, number>();
private nextTrackId = 0;
private subtitleOverride: number | null = null;
private suppressedTrack: VideoJsTextTrack | null = null;
constructor(private readonly config: VjsTextTracksConfig) {}
bind(): void {
const trackList = this.readTrackList();
if (trackList === this.trackList) {
this.applyCaptionState();
return;
}
this.detachTrackList();
this.resetSourceState();
this.trackList = trackList;
if (trackList && typeof trackList.addEventListener === 'function') {
const refresh: EventListener = () => {
this.applyCaptionState();
this.config.refresh();
};
this.refreshListener = refresh;
for (const event of TEXT_TRACK_EVENTS) {
trackList.addEventListener(event, refresh);
}
}
this.applyCaptionState();
}
clear(): void {
this.detachTrackList();
this.resetSourceState();
}
resetSource(): void {
this.resetSourceState();
this.applyCaptionState();
}
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.suppressedTrack = null;
this.applyOverride(tracks);
return;
}
if (id < 0 || !tracks.some((entry) => entry.id === id)) {
return;
}
this.subtitleOverride = id;
this.suppressedTrack = null;
this.applyOverride(tracks);
}
private readTrackList(): VideoJsTextTrackList | null {
try {
return this.config.player.textTracks();
} catch {
return null;
}
}
private detachTrackList(): void {
const trackList = this.trackList;
const refresh = this.refreshListener;
if (
trackList &&
refresh &&
typeof trackList.removeEventListener === 'function'
) {
for (const event of TEXT_TRACK_EVENTS) {
trackList.removeEventListener(event, refresh);
}
}
this.trackList = null;
this.refreshListener = null;
}
private resetSourceState(): void {
this.trackIds = new WeakMap<VideoJsTextTrack, number>();
this.nextTrackId = 0;
this.subtitleOverride = null;
this.suppressedTrack = null;
}
private readSubtitleTracks(): VjsSubtitleTrack[] {
const tracks = this.trackList;
if (!tracks) {
return [];
}
const result: VjsSubtitleTrack[] = [];
try {
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 {
const tracks = this.readSubtitleTracks();
if (this.subtitleOverride !== null) {
this.applyOverride(tracks);
return;
}
if (!this.config.showCaptions()) {
const selectedTrack = tracks.find(
({ track }) => track.mode === 'showing'
)?.track;
if (selectedTrack) {
this.suppressedTrack = selectedTrack;
} else if (
this.suppressedTrack &&
!tracks.some(({ track }) => track === this.suppressedTrack)
) {
this.suppressedTrack = null;
}
this.disableAll(tracks);
return;
}
const suppressedTrack = this.suppressedTrack;
this.suppressedTrack = null;
const selectedTrack =
suppressedTrack &&
tracks.some(({ track }) => track === suppressedTrack)
? suppressedTrack
: (tracks.find(({ track }) => track.mode === 'showing')
?.track ?? null);
this.applySelectedTrack(tracks, selectedTrack);
}
private applyOverride(tracks: VjsSubtitleTrack[]): void {
const selectedId =
this.subtitleOverride === -1 ? null : this.subtitleOverride;
for (const { id, track } of tracks) {
this.setMode(
track,
selectedId !== null && id === selectedId
? 'showing'
: 'disabled'
);
}
}
private applySelectedTrack(
tracks: VjsSubtitleTrack[],
selectedTrack: VideoJsTextTrack | null
): void {
for (const { track } of tracks) {
this.setMode(
track,
track === selectedTrack ? 'showing' : 'disabled'
);
}
}
private disableAll(tracks: VjsSubtitleTrack[]): void {
for (const { track } of tracks) {
this.setMode(track, 'disabled');
}
}
private setMode(track: VideoJsTextTrack, mode: TextTrackMode): void {
if (track.mode !== mode) {
track.mode = mode;
}
}
}
@@ -0,0 +1,71 @@
import { VjsVideoElementSession } from './vjs-video-element-session';
describe('VjsVideoElementSession', () => {
it('binds native playback events once and forwards them', () => {
const clearPlaybackIssue = jest.fn();
const emitPlaybackEnded = jest.fn();
const video = document.createElement('video');
const addEventListener = jest.spyOn(video, 'addEventListener');
const session = new VjsVideoElementSession({
clearPlaybackIssue,
emitPlaybackEnded,
});
session.bind(video);
session.bind(video);
video.dispatchEvent(new Event('loadeddata'));
video.dispatchEvent(new Event('playing'));
video.dispatchEvent(new Event('ended'));
expect(addEventListener).toHaveBeenCalledTimes(3);
expect(clearPlaybackIssue).toHaveBeenCalledTimes(2);
expect(emitPlaybackEnded).toHaveBeenCalledTimes(1);
expect(session.video()).toBe(video);
});
it('detaches the old Tech video before binding its replacement', () => {
const clearPlaybackIssue = jest.fn();
const emitPlaybackEnded = jest.fn();
const firstVideo = document.createElement('video');
const secondVideo = document.createElement('video');
const firstRemoveEventListener = jest.spyOn(
firstVideo,
'removeEventListener'
);
const session = new VjsVideoElementSession({
clearPlaybackIssue,
emitPlaybackEnded,
});
session.bind(firstVideo);
session.bind(secondVideo);
firstVideo.dispatchEvent(new Event('ended'));
secondVideo.dispatchEvent(new Event('ended'));
expect(firstRemoveEventListener).toHaveBeenCalledTimes(3);
expect(emitPlaybackEnded).toHaveBeenCalledTimes(1);
expect(session.video()).toBe(secondVideo);
});
it('destroys idempotently and ignores events from the detached video', () => {
const clearPlaybackIssue = jest.fn();
const emitPlaybackEnded = jest.fn();
const video = document.createElement('video');
const removeEventListener = jest.spyOn(video, 'removeEventListener');
const session = new VjsVideoElementSession({
clearPlaybackIssue,
emitPlaybackEnded,
});
session.bind(video);
session.destroy();
session.destroy();
video.dispatchEvent(new Event('loadeddata'));
video.dispatchEvent(new Event('ended'));
expect(removeEventListener).toHaveBeenCalledTimes(3);
expect(clearPlaybackIssue).not.toHaveBeenCalled();
expect(emitPlaybackEnded).not.toHaveBeenCalled();
expect(session.video()).toBeNull();
});
});
@@ -0,0 +1,61 @@
export interface VjsVideoElementSessionConfig {
clearPlaybackIssue: () => void;
emitPlaybackEnded: () => void;
}
export class VjsVideoElementSession {
private currentVideo: HTMLVideoElement | null = null;
private destroyed = false;
private readonly clearPlaybackIssue = () => {
this.config.clearPlaybackIssue();
};
private readonly emitPlaybackEnded = () => {
this.config.emitPlaybackEnded();
};
constructor(private readonly config: VjsVideoElementSessionConfig) {}
bind(video: HTMLVideoElement): void {
if (this.destroyed || this.currentVideo === video) {
return;
}
this.detach();
this.currentVideo = video;
video.addEventListener('loadeddata', this.clearPlaybackIssue);
video.addEventListener('playing', this.clearPlaybackIssue);
video.addEventListener('ended', this.emitPlaybackEnded);
}
video(): HTMLVideoElement | null {
return this.currentVideo;
}
destroy(): void {
if (this.destroyed) {
return;
}
this.detach();
this.destroyed = true;
}
private detach(): void {
if (!this.currentVideo) {
return;
}
this.currentVideo.removeEventListener(
'loadeddata',
this.clearPlaybackIssue
);
this.currentVideo.removeEventListener(
'playing',
this.clearPlaybackIssue
);
this.currentVideo.removeEventListener('ended', this.emitPlaybackEnded);
this.currentVideo = null;
}
}
@@ -3,6 +3,8 @@
<app-vjs-player
[options]="vjsOptions"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
@@ -65,6 +67,8 @@
<app-vjs-player
[options]="vjsOptions"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
@@ -35,6 +35,8 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
@@ -85,7 +87,7 @@ class StubEmbeddedMpvPlayerComponent {
readonly nextEpisodeRequested = output<void>();
}
describe('WebPlayerViewComponent shared HTML5 controls metadata', () => {
describe('WebPlayerViewComponent shared web controls metadata', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
let component: WebPlayerViewComponentInstance;
@@ -167,6 +169,18 @@ describe('WebPlayerViewComponent shared HTML5 controls metadata', () => {
expect(htmlPlayer.isLive()).toBe(false);
});
it('passes resolved playback metadata and caption preference to Video.js', async () => {
fixture.componentRef.setInput('showCaptions', true);
const vjsPlayer = await renderVjsPlayer({ isLive: false });
expect(vjsPlayer.options()).toEqual(
expect.objectContaining({ isLive: false })
);
expect(vjsPlayer.showCaptions()).toBe(true);
expect(vjsPlayer.interactionEnabled()).toBe(true);
});
it('disables HTML5 surface interaction while a diagnostic is visible', async () => {
const htmlPlayer = await renderHtmlPlayer();
@@ -199,6 +213,26 @@ describe('WebPlayerViewComponent shared HTML5 controls metadata', () => {
expect(htmlPlayer.interactionEnabled()).toBe(true);
});
it('disables and restores Video.js interaction around diagnostics', async () => {
const vjsPlayer = await renderVjsPlayer();
component.handlePlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(false);
component.retryPlayback();
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(true);
component.handlePlaybackIssue(createNetworkDiagnostic());
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(false);
component.handlePlaybackIssue(null);
fixture.detectChanges();
expect(vjsPlayer.interactionEnabled()).toBe(true);
});
it.each([
['inferred VOD', { contentInfo: createVodContentInfo() }, false],
[
@@ -251,6 +285,19 @@ describe('WebPlayerViewComponent shared HTML5 controls metadata', () => {
By.directive(StubHtmlVideoPlayerComponent)
).componentInstance as StubHtmlVideoPlayerComponent;
}
async function renderVjsPlayer(
metadata: Partial<ResolvedPortalPlayback> = {}
): Promise<StubVjsPlayerComponent> {
fixture.componentRef.setInput('playerOverride', VideoPlayer.VideoJs);
setPlayback(metadata);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
return fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
.componentInstance as StubVjsPlayerComponent;
}
});
function createVodContentInfo() {
@@ -36,6 +36,8 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
-2
View File
@@ -120,8 +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/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',
'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts',
'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts',