feat(player-controls): add shared engine-agnostic controls layer (#1148)

* feat(player-controls): shared engine-agnostic controls layer (flag off)

Introduce a shared, engine-agnostic player-controls layer in libs/ui/playback
as pure additive library code with no consumers yet.

- Contract (player-controls.model.ts): PlayerControlsCapabilities,
  PlayerControlsState, and PlayerControlsCommands make up the
  PlayerController interface every engine adapter implements.
- Single presentation component (app-player-controls): one controls UI
  binding purely to a PlayerController, with focused helpers for
  visibility auto-hide, volume, fullscreen (built-in DOM path), menus,
  keyboard shortcuts, seek/volume feedback, and the controls surface.
- Web-video adapter (web-video-controls.adapter.ts + host directive):
  drives the contract from an HTMLVideoElement, including optional
  HLS.js quality/audio-track integration and series episode navigation.
- Feature flag WEB_PLAYER_SHARED_CONTROLS (web-player-controls.flag.ts)
  defaults to OFF; no player component consumes the new layer yet, so
  runtime behavior is unchanged.
- docs/architecture/player-controls-contract.md documents the target
  architecture (later PRs add the embedded-MPV adapter, immersive
  overlay, and host-supplied fullscreen delegate).

Review-driven hardening: the web-video adapter now holds the host
series-navigation signal reactively (updates after setContext are
reflected); the shared controls template is fully localized via
ngx-translate (reusing the EMBEDDED_MPV.PLAYER.* keys); single click on
the viewport toggles play/pause deferred so a double-click still
fullscreens; keyboard shortcuts are shadow-DOM-safe (composedPath) and
guard against duplicate-instance double-execution (defaultPrevented);
and hidden controls now also disable shortcuts.

Test coverage extended per Codecov patch report.

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

* fix(player-controls): harden shared controls foundation

* fix(player-controls): restore detached adapter state

* fix(player-controls): harden keyboard and adapter state

* fix(player-controls): refresh readiness and hide timers

* fix(player-controls): keep controls root inside surface

* fix(player-controls): hide seek controls for live streams

* fix(player-controls): harden multi-engine control state

* fix(player-controls): respect runtime interaction availability

* fix(player-controls): gate loading toggles and localize mute

* fix(player-controls): harden surface and error states

* fix(player-controls): preserve volume and cursor state

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 4gray <serega05@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-07-16 12:56:30 +02:00
1 parent 59e08fd2d6
commit aa6ee85d3f
62 files changed
+7328

No files matched your search

+13
View File
@@ -128,6 +128,19 @@ Key files:
- `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video
- `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface
## Shared Player Controls
- `libs/ui/playback/src/lib/player-controls/` contains the additive,
engine-neutral `PlayerController` contract, standalone
`app-player-controls`, generic web-video adapter/helper, and default-off web
rollout token.
- No existing web or embedded-MPV engine consumes this layer yet. Current
player skins and embedded controls remain unchanged until the follow-up host
wiring lands.
- Frame-copy video is DOM-overlay-friendly. The native-view embedded-MPV engine
must retain its compositor-safe controls dock.
- Canonical contract: `docs/architecture/player-controls-contract.md`
## Repo Skills
- `iptvnator-ui-design`
+1
View File
@@ -618,6 +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. 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. Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
- Shared player-controls foundation (additive, not wired): `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 rollout token. No existing web or embedded-MPV engine consumes this layer yet, so current skins and embedded controls remain unchanged; web host wiring and an embedded-MPV adapter are follow-up work. The frame-copy canvas is overlay-friendly, while the native-view engine must retain its compositor-safe dock. Contract: `docs/architecture/player-controls-contract.md`.
**VOD/Series Detail Pages (two-state layout)**:
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "رجوع 10 ثوانٍ",
"FORWARD_10_SECONDS": "تقديم 10 ثوانٍ",
"LIVE_STREAM": "بث مباشر",
"PLAYBACK_POSITION": "موضع التشغيل",
"MUTE": "كتم الصوت",
"UNMUTE": "إلغاء كتم الصوت",
"MUTED": "الصوت مكتوم",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "رجع 10 ثواني",
"FORWARD_10_SECONDS": "قدّم 10 ثواني",
"LIVE_STREAM": "بث مباشر",
"PLAYBACK_POSITION": "موضع التشغيل",
"MUTE": "كتم الصوت",
"UNMUTE": "رجّع الصوت",
"MUTED": "الصوت مكتوم",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "Назад на 10 секунд",
"FORWARD_10_SECONDS": "Наперад на 10 секунд",
"LIVE_STREAM": "Прамая трансляцыя",
"PLAYBACK_POSITION": "Пазіцыя прайгравання",
"MUTE": "Адключыць гук",
"UNMUTE": "Уключыць гук",
"MUTED": "Гук адключаны",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "10 Sekunden zurück",
"FORWARD_10_SECONDS": "10 Sekunden vor",
"LIVE_STREAM": "Live-Stream",
"PLAYBACK_POSITION": "Wiedergabeposition",
"MUTE": "Stummschalten",
"UNMUTE": "Stummschaltung aufheben",
"MUTED": "Stummgeschaltet",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "Πίσω 10 δευτερόλεπτα",
"FORWARD_10_SECONDS": "Μπροστά 10 δευτερόλεπτα",
"LIVE_STREAM": "Ζωντανή ροή",
"PLAYBACK_POSITION": "Θέση αναπαραγωγής",
"MUTE": "Σίγαση",
"UNMUTE": "Κατάργηση σίγασης",
"MUTED": "Σε σίγαση",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "Back 10 seconds",
"FORWARD_10_SECONDS": "Forward 10 seconds",
"LIVE_STREAM": "Live stream",
"PLAYBACK_POSITION": "Playback position",
"MUTE": "Mute",
"UNMUTE": "Unmute",
"MUTED": "Muted",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "Retroceder 10 segundos",
"FORWARD_10_SECONDS": "Avanzar 10 segundos",
"LIVE_STREAM": "Transmisión en vivo",
"PLAYBACK_POSITION": "Posición de reproducción",
"MUTE": "Silenciar",
"UNMUTE": "Activar sonido",
"MUTED": "Silenciado",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "Reculer de 10 secondes",
"FORWARD_10_SECONDS": "Avancer de 10 secondes",
"LIVE_STREAM": "Diffusion en direct",
"PLAYBACK_POSITION": "Position de lecture",
"MUTE": "Muet",
"UNMUTE": "Activer le son",
"MUTED": "Son coupé",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "Indietro di 10 secondi",
"FORWARD_10_SECONDS": "Avanti di 10 secondi",
"LIVE_STREAM": "Stream in diretta",
"PLAYBACK_POSITION": "Posizione di riproduzione",
"MUTE": "Disattiva audio",
"UNMUTE": "Attiva audio",
"MUTED": "Audio disattivato",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "10秒戻る",
"FORWARD_10_SECONDS": "10秒進む",
"LIVE_STREAM": "ライブストリーム",
"PLAYBACK_POSITION": "再生位置",
"MUTE": "ミュート",
"UNMUTE": "ミュート解除",
"MUTED": "ミュート中",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "10초 뒤로",
"FORWARD_10_SECONDS": "10초 앞으로",
"LIVE_STREAM": "라이브 스트림",
"PLAYBACK_POSITION": "재생 위치",
"MUTE": "음소거",
"UNMUTE": "음소거 해제",
"MUTED": "음소거됨",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "10 seconden terug",
"FORWARD_10_SECONDS": "10 seconden vooruit",
"LIVE_STREAM": "Live stream",
"PLAYBACK_POSITION": "Afspeelpositie",
"MUTE": "Dempen",
"UNMUTE": "Dempen opheffen",
"MUTED": "Gedempt",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "Cofnij o 10 sekund",
"FORWARD_10_SECONDS": "Do przodu o 10 sekund",
"LIVE_STREAM": "Transmisja na żywo",
"PLAYBACK_POSITION": "Pozycja odtwarzania",
"MUTE": "Wycisz",
"UNMUTE": "Wyłącz wyciszenie",
"MUTED": "Wyciszono",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "Voltar 10 segundos",
"FORWARD_10_SECONDS": "Avançar 10 segundos",
"LIVE_STREAM": "Transmissão ao vivo",
"PLAYBACK_POSITION": "Posição de reprodução",
"MUTE": "Silenciar",
"UNMUTE": "Ativar som",
"MUTED": "Silenciado",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "Назад на 10 секунд",
"FORWARD_10_SECONDS": "Вперёд на 10 секунд",
"LIVE_STREAM": "Прямой эфир",
"PLAYBACK_POSITION": "Позиция воспроизведения",
"MUTE": "Отключить звук",
"UNMUTE": "Включить звук",
"MUTED": "Звук отключён",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "10 saniye geri",
"FORWARD_10_SECONDS": "10 saniye ileri",
"LIVE_STREAM": "Canlı yayın",
"PLAYBACK_POSITION": "Oynatma konumu",
"MUTE": "Sesi kapat",
"UNMUTE": "Sesi aç",
"MUTED": "Ses kapalı",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "快退 10 秒",
"FORWARD_10_SECONDS": "快进 10 秒",
"LIVE_STREAM": "直播",
"PLAYBACK_POSITION": "播放位置",
"MUTE": "静音",
"UNMUTE": "取消静音",
"MUTED": "已静音",
+1
View File
@@ -462,6 +462,7 @@
"BACK_10_SECONDS": "倒轉 10 秒",
"FORWARD_10_SECONDS": "快轉 10 秒",
"LIVE_STREAM": "現場直播",
"PLAYBACK_POSITION": "播放位置",
"MUTE": "靜音",
"UNMUTE": "取消靜音",
"MUTED": "已靜音",
@@ -0,0 +1,331 @@
# Player-Controls Contract
This document is the canonical reference for IPTVnator's additive,
engine-agnostic player-controls contract and shared default controls.
Embedded MPV rendering and native-view bounds behavior remain documented in
[embedded-mpv-native.md](./embedded-mpv-native.md).
## Current status
PR #1148 lands a shared-controls foundation only:
- the `PlayerController` contract, default state, and capability presets;
- the standalone `app-player-controls` presentation component and its
transient-state collaborators;
- a generic `WebVideoControlsAdapter` plus small host helpers;
- a default-off web rollout token; and
- focused unit/component tests.
No existing player consumes this layer yet. Video.js, html5+hls.js, ArtPlayer,
and embedded MPV keep their existing controls. The rollout token has no runtime
consumer in #1148, so changing it alone does not switch any player UI.
Engine-host wiring and an embedded-MPV adapter are follow-up work.
This distinction is intentional: the contract can be reviewed and hardened
without changing shipped playback behavior.
## Why this exists
Each playback engine currently owns both media integration and controls UI.
That makes behavior drift likely and makes a controls redesign depend on each
engine's implementation details.
The shared contract separates:
- **presentation** — what the controls render and which interactions they own;
- **state and capabilities** — the engine-neutral snapshot the UI reads; and
- **commands** — the small imperative surface an engine adapter implements.
Rendering and compositing remain engine responsibilities. In particular, the
contract does not make a native video surface behave like DOM content.
## Landed architecture
```text
┌──────────────────────────────────────────────────────────────────────┐
│ app-player-controls │
│ Standalone shared presentation component │
│ Menus · feedback · auto-hide · DOM fullscreen · shortcuts · scrub UI│
└───────────────────────────────┬──────────────────────────────────────┘
│ input.required<PlayerController>()
▼
┌──────────────────────────────────────────────────────────────────────┐
│ PlayerController │
│ capabilities: Signal<PlayerControlsCapabilities> │
│ state: Signal<PlayerControlsState> │
│ commands: PlayerControlsCommands │
└───────────────────────────────┬──────────────────────────────────────┘
│
adapters implement this boundary
│
┌──────────────────┴──────────────────┐
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ WebVideoControlsAdapter │ │ Embedded-MPV adapter │
│ Landed, generic, not wired │ │ FOLLOW-UP: not implemented │
└──────────────────────────────┘ └──────────────────────────────┘
```
The diagram shows the reusable boundary, not current runtime ownership. There
is no `app-player-controls` host in an existing engine component in #1148.
## The contract
The contract is defined in
`libs/ui/playback/src/lib/player-controls/player-controls.model.ts`.
```ts
interface PlayerController {
readonly capabilities: Signal<PlayerControlsCapabilities>;
readonly state: Signal<PlayerControlsState>;
readonly commands: PlayerControlsCommands;
}
```
### Capabilities
`PlayerControlsCapabilities` contains booleans for `seek`, `volume`,
`audioTracks`, `subtitles`, `playbackSpeed`, `aspectRatio`, `recording`,
`fullscreen`, and `seriesNavigation`.
The default is all-false. An adapter enables only features that its engine and
current runtime support. Capability flags primarily control whether optional UI
is rendered; state such as `canSeek`, `canPreviousEpisode`, and
`canNextEpisode` guards the corresponding action at runtime.
### State
`PlayerControlsState` is one reactive engine-neutral snapshot:
- playback status, loading/error message, and stalled state;
- current position, optional duration, live/VOD classification, and seekability;
- volume;
- pre-labelled audio/subtitle tracks and subtitle-enabled state;
- playback speed and aspect-ratio selections/presets;
- recording state; and
- previous/next episode availability.
Adapters translate engine types into this model. The controls component must not
import Video.js, hls.js, ArtPlayer, libmpv, Electron IPC, or native-view types.
### Commands
`PlayerControlsCommands` is an imperative, fire-and-forget surface:
- `togglePlay`
- `seekTo` / `seekBy`
- `setVolume`
- `setAudioTrack` / `setSubtitleTrack`
- `setPlaybackSpeed`
- `setAspectRatio`
- `toggleRecording`
Episode navigation is deliberately exposed as component outputs
(`previousEpisodeRequested` and `nextEpisodeRequested`) because the owning
playlist/portal feature decides which item to play.
Fullscreen is also outside the engine command contract. The landed component
uses `ControlsFullscreen`, which operates on the supplied DOM player surface
through `requestFullscreen()` / `document.exitFullscreen()`. There is no
fullscreen delegate or native-fullscreen IPC path in #1148.
## Shared default controls
`PlayerControlsComponent` is a standalone presentation component designed for a
future engine host to mount over or beside its playback surface.
It owns only transient presentation behavior:
- `ControlsMenuState` — single-open popovers;
- `ControlsFeedback` — temporary action feedback;
- `ControlsVisibility` — reveal and auto-hide state;
- `ControlsFullscreen` — DOM fullscreen;
- `ControlsVolume` — persisted/optimistic volume state reconciled from
controller state;
- `ControlsShortcuts` — document keyboard routing;
- `ControlsSurface` — pointer/click/double-click surface interactions; and
- `controls-view-model.ts` — derived display state.
### Keyboard ownership
Unmodified Space/K, F, arrow keys, and M are playback shortcuts. Playback keys
with Meta/Cmd, Control, or Alt are ignored and are not prevented, so app and OS
accelerators retain ownership. Escape remains available to close controls
popovers even when a modifier is held or playback shortcuts are unavailable.
Buttons, form controls, links, ARIA menu controls, and content-editable targets
are also ignored anywhere in the event's composed path.
Action-specific keys are prevented only when the active controller can handle
them: seek requires both capability and current seekability, volume/mute
requires volume capability, and fullscreen requires an available DOM
fullscreen path. Unsupported keys retain their browser or application default.
When multiple shared-controls instances are mounted, the first attached
instance owns shortcuts initially. Pointer, focus, or control interaction
activates that instance through the normal reveal path. If the active instance
becomes unavailable, playback shortcuts fall back to the most recently attached
available instance; detaching the active instance also transfers ownership.
Escape remains a global dismissal action and closes popovers on every mounted
controls instance.
Auto-hide pauses while the pointer is over the controls bar or keyboard focus
is anywhere inside it. Focus entering a hidden bar reveals it; moving focus
between controls does not restart hiding, and leaving the bar resumes the normal
hide delay. In fullscreen playback, hiding the controls also hides the pointer
over both the controls host and the supplied player surface; revealing controls
or destroying the component restores the surface's previous inline cursor.
Open popovers are reconciled against the current capability and state snapshot.
If controls are hidden, a capability is removed, or the corresponding track
list becomes unavailable, the stale popover closes instead of pinning the
controls visible or consuming the next surface click.
Setting `showControls` to false also detaches playback-surface pointer, click,
and double-click handling. A hidden shared-controls instance therefore cannot
reveal, pause, or fullscreen the player underneath another UI layer.
### Timeline scrubbing
Timeline input is previewed locally while the user drags. The slider value,
played progress, accessible value text, and current-time label all render the
preview. The component sends exactly one `seekTo` command on the committed
`change` event, then clears the preview and returns to controller-reported
state. Non-finite values are ignored and finite values are clamped to the
available `[0, duration]` range.
The scrub slider and seek shortcuts require both the `seek` capability and
seekable runtime state. When seek is unsupported, the slider is omitted while
live and recording status remain visible. Volume shortcuts likewise require the
`volume` capability.
When a volume-capable controller first attaches, an existing `localStorage`
volume preference is applied before the first controller snapshot can reconcile
the optimistic value. With no saved preference, the controller snapshot remains
authoritative. If the same controller loses and later regains the volume
capability, initialization runs again for the new capability epoch. The volume
slider intentionally remains continuous: each volume `input` applies the
optimistic volume immediately.
## Web adapter (landed, not wired)
`WebVideoControlsAdapter` can translate an `HTMLVideoElement` into the shared
contract. It uses DOM/media events and accepts optional engine-specific track
accessors through `WebVideoControlsOptions`, so the adapter itself stays usable
in the PWA and does not import a concrete web engine.
Native media events refresh the adapter automatically. A future engine host
must call the public `refresh()` hook after engine-specific getters change
without a corresponding media event, including track lists, corrected duration,
or live/VOD classification. Source, readiness, progress, seeking, and playback
events that can invalidate the snapshot are observed directly.
Audio and subtitle capabilities are advertised only when the injected getter
returns a selectable list and the corresponding setter exists. Track setters
may complete synchronously or asynchronously; the adapter refreshes after
successful completion and contains synchronous throws or rejected promises
while an engine is changing source.
An injected non-`NaN` duration is authoritative, including positive infinity;
`NaN` falls back to the video element. Without an explicit `isLive` accessor,
only positive infinity implies live playback, so unknown duration is not
temporarily mislabeled as live. An attached element with no resource maps to
`idle`, paused preload/warm-up remains playable, and only actively playing media
with insufficient data maps to `loading`.
`web-video-controls.host.ts` contains small attachment/projection helpers for a
future host integration. No Video.js, html5+hls.js, or ArtPlayer component calls
those helpers in #1148.
The rollout symbols are:
| Symbol | Default | Current effect |
| ------------------------------------ | ------------: | ----------------------------------------------------------------------------------- |
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Documents the intended rollout default. |
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view of the default. No runtime player consumes it yet. |
A follow-up web integration must explicitly consume the token, attach the
adapter to the active video element, mount `app-player-controls`, and only then
disable the engine's existing skin.
## Embedded MPV rendering constraints
The shared contract does not replace the existing embedded-MPV renderer or
controls in #1148. Any follow-up embedded adapter must preserve the two current
rendering paths.
### Frame-copy engine
The experimental frame-copy engine uploads helper-produced frames to
`<canvas data-embedded-mpv-frame>`. The canvas is ordinary DOM, so controls,
dialogs, and other DOM layers can stack above it normally. This makes the
frame-copy path overlay-friendly, although the current embedded player still
uses its existing controls until follow-up shared-controls wiring lands.
### Native-view engine
The native MPV surface paints outside Chromium's DOM stacking model. It keeps
the compositor-safe fixed controls dock below the viewport. Modal overlays hide
the native surface with `HIDDEN_BOUNDS`, and control popovers reserve a bottom
cutout so their DOM region remains interactive.
The transparent BrowserWindow / `NSWindowBelow` tunnel-and-backdrop approach is
not the shipped architecture. #1148 does not add transparency changes, backdrop
holes, native fullscreen IPC, native-view attachment APIs, or bounds-tick
machinery.
See [embedded-mpv-native.md](./embedded-mpv-native.md) for the authoritative
renderer, bounds, and platform details.
## Follow-up integrations
The following are design seams, not shipped #1148 behavior:
1. **Web hosts** — mount the component, consume the rollout token, attach
`WebVideoControlsAdapter`, and remove an engine skin only when the shared
controls are active.
2. **Embedded-MPV adapter** — implement `PlayerController` over
`EmbeddedMpvSessionController` without changing session ownership or native
rendering behavior.
3. **Embedded-MPV layout** — use normal DOM layering for frame-copy; retain a
compositor-safe dock for the native-view engine unless that engine's
compositing architecture changes independently.
4. **Background playback** — introduce a persistent player/session host above
route-scoped views. The contract is lifecycle-agnostic, but #1148 does not
add that host or change current teardown behavior.
## File map
Landed in #1148:
```text
libs/ui/playback/src/lib/player-controls/
├── player-controls.model.ts
├── player-controls-defaults.ts
├── player-controls.component.ts
├── player-controls.component.html
├── player-controls.component.scss
├── controls-feedback.ts
├── controls-format.utils.ts
├── controls-fullscreen.ts
├── controls-menu-selection.ts
├── controls-menu-state.ts
├── controls-shortcuts.ts
├── controls-surface.ts
├── controls-view-model.ts
├── controls-visibility.ts
├── controls-volume.ts
├── web-player-controls.flag.ts
├── web-video-controls.adapter.ts
├── web-video-controls.host.ts
└── index.ts
```
Focused specs live beside these files. The subtree is exported from
`libs/ui/playback/src/index.ts`.
Not landed in #1148:
- an embedded-MPV `PlayerController` adapter;
- a shared-controls host inside an existing web or embedded player;
- removal/replacement of any current engine skin; and
- persistent/background player ownership.
+1
View File
@@ -6,6 +6,7 @@ export * from './lib/embedded-mpv-player/embedded-mpv-session-controller';
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
export * from './lib/html-video-player/html-video-player.component';
export * from './lib/playback-diagnostics/playback-diagnostics.util';
export * from './lib/player-controls';
export * from './lib/portal-inline-player/portal-inline-player.component';
export * from './lib/portal-inline-player/series-playback-navigation';
export * from './lib/vjs-player/vjs-player.component';
@@ -0,0 +1,59 @@
import { ControlsFeedback } from './controls-feedback';
describe('ControlsFeedback', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('flashes feedback and clears the previous timeout when replaced', () => {
const feedback = new ControlsFeedback();
feedback.flash('volume_up', '60%', 700);
expect(feedback.current()).toEqual({
icon: 'volume_up',
label: '60%',
key: 1,
});
jest.advanceTimersByTime(500);
feedback.flash('volume_down', '55%', 700);
expect(feedback.current()).toEqual({
icon: 'volume_down',
label: '55%',
key: 2,
});
jest.advanceTimersByTime(699);
expect(feedback.current()?.label).toBe('55%');
jest.advanceTimersByTime(1);
expect(feedback.current()).toBeNull();
});
it('disposes pending feedback timers', () => {
const feedback = new ControlsFeedback();
feedback.flash('volume_off', 'Muted', 700);
feedback.dispose();
jest.advanceTimersByTime(700);
expect(feedback.current()?.label).toBe('Muted');
});
it('uses caller-provided recording transition labels', () => {
const feedback = new ControlsFeedback();
const labels = {
active: 'Aufnahme',
inactive: 'Aufnahme gespeichert',
};
feedback.flashRecordingTransition(true, labels);
expect(feedback.current()?.label).toBe('Aufnahme');
feedback.flashRecordingTransition(false, labels);
expect(feedback.current()?.label).toBe('Aufnahme gespeichert');
});
});
@@ -0,0 +1,61 @@
import { signal } from '@angular/core';
export interface RecordingFeedbackLabels {
active: string;
inactive: string;
}
/**
* Transient feedback overlay shown when the user adjusts volume/seek/mute via
* keyboard. Caller calls flash() with an icon + label; auto-clears after the
* given duration.
*/
export class ControlsFeedback {
readonly current = signal<{
icon: string;
label: string;
key: number;
} | null>(null);
private timer: number | null = null;
private nextKey = 0;
private lastRecordingActive = false;
flash(icon: string, label: string, durationMs = 700): void {
if (this.timer !== null) {
clearTimeout(this.timer);
}
this.nextKey += 1;
this.current.set({ icon, label, key: this.nextKey });
this.timer = window.setTimeout(() => {
this.current.set(null);
this.timer = null;
}, durationMs);
}
/**
* Flash a transient overlay on recording start/stop transitions, ignoring
* repeated states. Tracks the last known active state internally.
*/
flashRecordingTransition(
active: boolean,
labels: RecordingFeedbackLabels
): void {
if (active === this.lastRecordingActive) {
return;
}
this.lastRecordingActive = active;
if (active) {
this.flash('fiber_manual_record', labels.active, 900);
} else {
this.flash('stop_circle', labels.inactive, 900);
}
}
dispose(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}
}
@@ -0,0 +1,41 @@
import {
formatTime,
persistVolume,
readStoredVolume,
volumeIcon,
volumeLabel,
} from './controls-format.utils';
describe('controls format utilities', () => {
afterEach(() => {
localStorage.clear();
});
it('formats playback time with hour rollover and safe null values', () => {
expect(formatTime(null)).toBe('0:00');
expect(formatTime(-30)).toBe('0:00');
expect(formatTime(75.9)).toBe('1:15');
expect(formatTime(3661)).toBe('1:01:01');
});
it('clamps stored volume reads and persists raw volume values', () => {
localStorage.setItem('volume', '2');
expect(readStoredVolume()).toBe(1);
localStorage.setItem('volume', '-0.5');
expect(readStoredVolume()).toBe(0);
localStorage.setItem('volume', 'not-a-number');
expect(readStoredVolume()).toBe(1);
persistVolume(0.35);
expect(localStorage.getItem('volume')).toBe('0.35');
});
it('maps volume to an icon and a readable label', () => {
expect(volumeIcon(0)).toBe('volume_off');
expect(volumeIcon(0.25)).toBe('volume_down');
expect(volumeIcon(0.75)).toBe('volume_up');
expect(volumeLabel(0.755)).toBe('Volume 76%');
});
});
@@ -0,0 +1,43 @@
export function formatTime(value: number | null | undefined): string {
const safeValue = Math.max(0, Math.floor(value ?? 0));
const hours = Math.floor(safeValue / 3600);
const minutes = Math.floor((safeValue % 3600) / 60);
const seconds = safeValue % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(
seconds
).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
export function volumeIcon(value: number): string {
if (value <= 0) {
return 'volume_off';
}
return value < 0.5 ? 'volume_down' : 'volume_up';
}
export function volumeLabel(value: number): string {
return `Volume ${Math.round(value * 100)}%`;
}
/** Rounds and formats a playback rate as e.g. `1.5×` for tooltips. */
export function speedLabel(speed: number): string {
const value = Math.round(speed * 100) / 100;
return `${value}×`;
}
export function readStoredVolume(): number {
const rawValue = Number(localStorage.getItem('volume') ?? '1');
if (Number.isNaN(rawValue)) {
return 1;
}
return Math.max(0, Math.min(1, rawValue));
}
export function persistVolume(value: number): void {
localStorage.setItem('volume', String(value));
}
@@ -0,0 +1,82 @@
import { ControlsFullscreen } from './controls-fullscreen';
describe('ControlsFullscreen', () => {
let target: HTMLElement;
let requestFullscreen: jest.Mock;
let exitFullscreen: jest.Mock;
let originalExit: typeof document.exitFullscreen;
const setFullscreenElement = (element: Element | null) => {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
value: element,
});
};
beforeEach(() => {
target = document.createElement('div');
requestFullscreen = jest.fn().mockResolvedValue(undefined);
exitFullscreen = jest.fn().mockResolvedValue(undefined);
(target as HTMLElement & { requestFullscreen: jest.Mock }).requestFullscreen =
requestFullscreen;
originalExit = document.exitFullscreen;
document.exitFullscreen = exitFullscreen;
setFullscreenElement(null);
});
afterEach(() => {
document.exitFullscreen = originalExit;
setFullscreenElement(null);
});
it('reports capability based on the target and document support', () => {
const fs = new ControlsFullscreen(() => target);
expect(fs.canFullscreen()).toBe(true);
fs.dispose();
const noTarget = new ControlsFullscreen(() => null);
expect(noTarget.canFullscreen()).toBe(false);
noTarget.dispose();
});
it('requests fullscreen on the target when not already fullscreen', async () => {
const fs = new ControlsFullscreen(() => target);
await fs.toggle();
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(exitFullscreen).not.toHaveBeenCalled();
fs.dispose();
});
it('exits fullscreen when the target is already fullscreen', async () => {
setFullscreenElement(target);
const fs = new ControlsFullscreen(() => target);
await fs.toggle();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(requestFullscreen).not.toHaveBeenCalled();
fs.dispose();
});
it('tracks fullscreen state and invokes onChange on fullscreenchange', () => {
const onChange = jest.fn();
const fs = new ControlsFullscreen(() => target, onChange);
setFullscreenElement(target);
document.dispatchEvent(new Event('fullscreenchange'));
expect(fs.isFullscreen()).toBe(true);
expect(onChange).toHaveBeenCalledTimes(1);
setFullscreenElement(null);
document.dispatchEvent(new Event('fullscreenchange'));
expect(fs.isFullscreen()).toBe(false);
fs.dispose();
});
it('stops reacting to fullscreenchange after dispose', () => {
const onChange = jest.fn();
const fs = new ControlsFullscreen(() => target, onChange);
fs.dispose();
setFullscreenElement(target);
document.dispatchEvent(new Event('fullscreenchange'));
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,65 @@
import { signal } from '@angular/core';
/**
* Owns the fullscreen lifecycle for a player surface: tracks the current
* fullscreen state, listens for `fullscreenchange`, and toggles fullscreen on
* the target element. The component keeps the capability gating and the
* label/icon derivation; this helper only deals with the browser API.
*/
export class ControlsFullscreen {
readonly isFullscreen = signal(false);
private readonly onFullscreenChange = () => {
const target = this.target();
this.isFullscreen.set(
Boolean(target && document.fullscreenElement === target)
);
this.onChange?.();
};
constructor(
private readonly target: () => HTMLElement | null,
private readonly onChange?: () => void
) {
if (typeof document !== 'undefined') {
document.addEventListener(
'fullscreenchange',
this.onFullscreenChange
);
}
}
canFullscreen(): boolean {
const target = this.target();
return (
typeof document !== 'undefined' &&
Boolean(target?.requestFullscreen) &&
Boolean(document.exitFullscreen)
);
}
async toggle(): Promise<void> {
const target = this.target();
if (!target || !this.canFullscreen()) {
return;
}
try {
if (document.fullscreenElement === target) {
await document.exitFullscreen();
} else {
await target.requestFullscreen();
}
} catch {
return;
}
}
dispose(): void {
if (typeof document !== 'undefined') {
document.removeEventListener(
'fullscreenchange',
this.onFullscreenChange
);
}
}
}
@@ -0,0 +1,87 @@
import { ControlsMenuSelection } from './controls-menu-selection';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsVisibility } from './controls-visibility';
import type { PlayerControlsCommands } from './player-controls.model';
describe('ControlsMenuSelection', () => {
let commands: jest.Mocked<PlayerControlsCommands>;
let menus: ControlsMenuState;
let visibility: ControlsVisibility;
let revealSticky: jest.Mock;
let selection: ControlsMenuSelection;
beforeEach(() => {
commands = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
};
menus = new ControlsMenuState();
visibility = new ControlsVisibility(() => false);
jest.spyOn(visibility, 'scheduleHide');
revealSticky = jest.fn();
selection = new ControlsMenuSelection({
commands: () => commands,
menus,
visibility,
revealSticky,
});
});
it('toggles a menu through the menu state', () => {
selection.toggle('audio');
expect(menus.audioOpen()).toBe(true);
selection.toggle('audio');
expect(menus.audioOpen()).toBe(false);
});
it('selects an audio track: reveal sticky, command, close menu, reschedule hide', () => {
menus.open('audio');
selection.audioTrack(3);
expect(revealSticky).toHaveBeenCalledTimes(1);
expect(commands.setAudioTrack).toHaveBeenCalledWith(3);
expect(menus.audioOpen()).toBe(false);
expect(visibility.scheduleHide).toHaveBeenCalledTimes(1);
});
it('selects a subtitle track and closes the subtitle menu', () => {
menus.open('subtitle');
selection.subtitleTrack(-1);
expect(commands.setSubtitleTrack).toHaveBeenCalledWith(-1);
expect(menus.subtitleOpen()).toBe(false);
expect(visibility.scheduleHide).toHaveBeenCalled();
});
it('applies a playback speed and closes the speed menu', () => {
menus.open('speed');
selection.speed(1.5);
expect(commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5);
expect(menus.speedOpen()).toBe(false);
});
it('applies an aspect ratio and closes the aspect menu', () => {
menus.open('aspect');
selection.aspect('16:9');
expect(commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(menus.aspectOpen()).toBe(false);
});
it('only closes the menu the selection belongs to', () => {
menus.open('volume');
selection.speed(2);
// Closing 'speed' must not blindly close every popover.
expect(menus.volumeOpen()).toBe(true);
});
});
@@ -0,0 +1,49 @@
import type { ControlsMenuState } from './controls-menu-state';
import type { ControlsVisibility } from './controls-visibility';
import type { PlayerControlsCommands } from './player-controls.model';
type MenuKey = 'audio' | 'subtitle' | 'speed' | 'aspect';
export interface MenuSelectionDeps {
commands: () => PlayerControlsCommands;
menus: ControlsMenuState;
visibility: ControlsVisibility;
/** Reveal without rescheduling the auto-hide (kept open while choosing). */
revealSticky: () => void;
}
/**
* Owns track/speed/aspect menu selection: reveal-without-hide, run the command,
* close the menu, then reschedule the auto-hide. Keeps the component lean while
* the template still binds to thin delegating methods.
*/
export class ControlsMenuSelection {
constructor(private readonly deps: MenuSelectionDeps) {}
toggle(menu: MenuKey): void {
this.deps.menus.toggle(menu);
}
audioTrack(trackId: number): void {
this.apply('audio', (c) => c.setAudioTrack(trackId));
}
subtitleTrack(trackId: number): void {
this.apply('subtitle', (c) => c.setSubtitleTrack(trackId));
}
speed(value: number): void {
this.apply('speed', (c) => c.setPlaybackSpeed(value));
}
aspect(value: string): void {
this.apply('aspect', (c) => c.setAspectRatio(value));
}
private apply(menu: MenuKey, run: (c: PlayerControlsCommands) => void): void {
this.deps.revealSticky();
run(this.deps.commands());
this.deps.menus.close(menu);
this.deps.visibility.scheduleHide();
}
}
@@ -0,0 +1,122 @@
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { ControlsMenuState } from './controls-menu-state';
describe('ControlsMenuState', () => {
it('keeps only one menu open at a time', () => {
const menus = new ControlsMenuState();
menus.open('volume');
expect(menus.volumeOpen()).toBe(true);
expect(menus.anyOpen()).toBe(true);
menus.open('audio');
expect(menus.volumeOpen()).toBe(false);
expect(menus.audioOpen()).toBe(true);
menus.toggle('audio');
expect(menus.audioOpen()).toBe(false);
expect(menus.anyOpen()).toBe(false);
});
it('closes all menus', () => {
const menus = new ControlsMenuState();
menus.open('speed');
menus.closeAll();
expect(menus.anyOpen()).toBe(false);
});
it.each(['volume', 'audio', 'subtitle', 'speed', 'aspect'] as const)(
'closes an open %s menu when it becomes unavailable',
(menu) => {
const menus = new ControlsMenuState();
menus.open(menu);
const changed = menus.reconcile({
volume: menu !== 'volume',
audio: menu !== 'audio',
subtitle: menu !== 'subtitle',
speed: menu !== 'speed',
aspect: menu !== 'aspect',
});
expect(changed).toBe(true);
expect(menus.anyOpen()).toBe(false);
}
);
it('leaves an available menu open without reporting a change', () => {
const menus = new ControlsMenuState();
menus.open('speed');
expect(
menus.reconcile({
volume: true,
audio: true,
subtitle: true,
speed: true,
aspect: true,
})
).toBe(false);
expect(menus.speedOpen()).toBe(true);
});
it('closes every unavailable menu if state was made inconsistent', () => {
const menus = new ControlsMenuState();
menus.volumeOpen.set(true);
menus.speedOpen.set(true);
expect(
menus.reconcile({
volume: false,
audio: false,
subtitle: false,
speed: false,
aspect: false,
})
).toBe(true);
expect(menus.anyOpen()).toBe(false);
});
it.each([
['volume', { volume: false }, {}],
['audio', {}, { audioTracks: [] }],
['subtitle', {}, { subtitleTracks: [] }],
['speed', { playbackSpeed: false }, {}],
['aspect', { aspectRatio: false }, {}],
] as const)(
'maps runtime controller state to %s menu availability',
(menu, capabilityOverrides, stateOverrides) => {
const menus = new ControlsMenuState();
menus.open(menu);
menus.reconcileControllerAvailability(
true,
{
...DEFAULT_PLAYER_CAPABILITIES,
volume: true,
audioTracks: true,
subtitles: true,
playbackSpeed: true,
aspectRatio: true,
...capabilityOverrides,
},
{
...createEmptyControlsState(),
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
subtitleTracks: [
{ id: 1, label: 'English', selected: true },
],
...stateOverrides,
}
);
expect(menus.anyOpen()).toBe(false);
}
);
});
@@ -0,0 +1,121 @@
import { computed, signal } from '@angular/core';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
} from './player-controls.model';
const CONTROL_MENUS = [
'volume',
'audio',
'subtitle',
'speed',
'aspect',
] as const;
export type ControlsMenu = (typeof CONTROL_MENUS)[number];
export type ControlsMenuAvailability = Readonly<Record<ControlsMenu, boolean>>;
function getControlsMenuAvailability(
showControls: boolean,
capabilities: PlayerControlsCapabilities,
state: PlayerControlsState
): ControlsMenuAvailability {
return {
volume: showControls && capabilities.volume,
audio:
showControls &&
capabilities.audioTracks &&
state.audioTracks.length > 1,
subtitle:
showControls &&
capabilities.subtitles &&
state.subtitleTracks.length > 0,
speed: showControls && capabilities.playbackSpeed,
aspect: showControls && capabilities.aspectRatio,
};
}
/**
* Tracks which menu/popover is currently open and exposes individual signals
* the template binds to. Only one menu can be open at a time.
*/
export class ControlsMenuState {
readonly volumeOpen = signal(false);
readonly audioOpen = signal(false);
readonly subtitleOpen = signal(false);
readonly speedOpen = signal(false);
readonly aspectOpen = signal(false);
readonly anyOpen = computed(
() =>
this.volumeOpen() ||
this.audioOpen() ||
this.subtitleOpen() ||
this.speedOpen() ||
this.aspectOpen()
);
toggle(menu: ControlsMenu): void {
const target = this.signalFor(menu);
const next = !target();
this.closeAll();
target.set(next);
}
open(menu: ControlsMenu): void {
if (this.signalFor(menu)()) {
return;
}
this.closeAll();
this.signalFor(menu).set(true);
}
close(menu: ControlsMenu): void {
this.signalFor(menu).set(false);
}
closeAll(): void {
this.volumeOpen.set(false);
this.audioOpen.set(false);
this.subtitleOpen.set(false);
this.speedOpen.set(false);
this.aspectOpen.set(false);
}
reconcile(availability: ControlsMenuAvailability): boolean {
let changed = false;
for (const menu of CONTROL_MENUS) {
const open = this.signalFor(menu);
if (open() && !availability[menu]) {
open.set(false);
changed = true;
}
}
return changed;
}
reconcileControllerAvailability(
showControls: boolean,
capabilities: PlayerControlsCapabilities,
state: PlayerControlsState
): boolean {
return this.reconcile(
getControlsMenuAvailability(showControls, capabilities, state)
);
}
private signalFor(menu: ControlsMenu) {
switch (menu) {
case 'volume':
return this.volumeOpen;
case 'audio':
return this.audioOpen;
case 'subtitle':
return this.subtitleOpen;
case 'speed':
return this.speedOpen;
case 'aspect':
return this.aspectOpen;
}
}
}
@@ -0,0 +1,266 @@
import { ControlsShortcuts } from './controls-shortcuts';
describe('ControlsShortcuts', () => {
let shortcuts: ControlsShortcuts;
let handlers: {
isAvailable: jest.Mock<boolean, []>;
canTogglePaused: jest.Mock<boolean, []>;
canSeek: jest.Mock<boolean, []>;
canAdjustVolume: jest.Mock<boolean, []>;
canToggleFullscreen: jest.Mock<boolean, []>;
onEscape: jest.Mock;
togglePaused: jest.Mock;
toggleFullscreen: jest.Mock;
seekBy: jest.Mock;
adjustVolume: jest.Mock;
toggleMute: jest.Mock;
};
beforeEach(() => {
shortcuts = new ControlsShortcuts();
handlers = {
isAvailable: jest.fn(() => true),
canTogglePaused: jest.fn(() => true),
canSeek: jest.fn(() => true),
canAdjustVolume: jest.fn(() => true),
canToggleFullscreen: jest.fn(() => true),
onEscape: jest.fn(),
togglePaused: jest.fn(),
toggleFullscreen: jest.fn(),
seekBy: jest.fn(),
adjustVolume: jest.fn(),
toggleMute: jest.fn(),
};
shortcuts.attach(handlers);
});
afterEach(() => {
shortcuts.detach();
});
it('forwards playback, seek, volume, and fullscreen shortcuts', () => {
expect(dispatchKey(' ')).toBe(true);
expect(dispatchKey('ArrowLeft')).toBe(true);
expect(dispatchKey('ArrowRight')).toBe(true);
expect(dispatchKey('ArrowUp')).toBe(true);
expect(dispatchKey('ArrowDown')).toBe(true);
expect(dispatchKey('f')).toBe(true);
expect(dispatchKey('M')).toBe(true);
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
expect(handlers.seekBy).toHaveBeenCalledWith(-5);
expect(handlers.seekBy).toHaveBeenCalledWith(5);
expect(handlers.adjustVolume).toHaveBeenCalledWith(0.05);
expect(handlers.adjustVolume).toHaveBeenCalledWith(-0.05);
expect(handlers.toggleFullscreen).toHaveBeenCalledTimes(1);
expect(handlers.toggleMute).toHaveBeenCalledTimes(1);
});
it('does not consume keys for unsupported actions', () => {
handlers.canTogglePaused.mockReturnValue(false);
handlers.canSeek.mockReturnValue(false);
handlers.canAdjustVolume.mockReturnValue(false);
handlers.canToggleFullscreen.mockReturnValue(false);
expect(dispatchKey('k')).toBe(false);
expect(dispatchKey('ArrowRight')).toBe(false);
expect(dispatchKey('ArrowDown')).toBe(false);
expect(dispatchKey('m')).toBe(false);
expect(dispatchKey('f')).toBe(false);
expect(handlers.togglePaused).not.toHaveBeenCalled();
expect(handlers.seekBy).not.toHaveBeenCalled();
expect(handlers.adjustVolume).not.toHaveBeenCalled();
expect(handlers.toggleMute).not.toHaveBeenCalled();
expect(handlers.toggleFullscreen).not.toHaveBeenCalled();
});
it('always allows escape to close popovers even when playback is unavailable', () => {
handlers.isAvailable.mockReturnValue(false);
expect(dispatchKey('k')).toBe(false);
expect(dispatchKey('Escape')).toBe(false);
expect(handlers.onEscape).toHaveBeenCalledTimes(1);
expect(handlers.togglePaused).not.toHaveBeenCalled();
});
it('ignores shortcuts inside form controls', () => {
const input = document.createElement('input');
const select = document.createElement('select');
document.body.append(input, select);
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowUp',
bubbles: true,
cancelable: true,
})
);
select.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'm',
bubbles: true,
cancelable: true,
})
);
expect(handlers.adjustVolume).not.toHaveBeenCalled();
expect(handlers.toggleMute).not.toHaveBeenCalled();
input.remove();
select.remove();
});
it('ignores shortcuts from buttons and ARIA menu controls', () => {
const button = document.createElement('button');
const menuItem = document.createElement('div');
menuItem.setAttribute('role', 'menuitemradio');
document.body.append(button, menuItem);
button.focus();
button.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
})
);
const menuEvent = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
});
Object.defineProperty(menuEvent, 'composedPath', {
value: () => [menuItem, document.body, document],
});
document.dispatchEvent(menuEvent);
expect(handlers.togglePaused).not.toHaveBeenCalled();
button.remove();
menuItem.remove();
});
it('ignores modified playback shortcuts without preventing their defaults', () => {
expect(dispatchKey('k', { metaKey: true })).toBe(false);
expect(dispatchKey('f', { ctrlKey: true })).toBe(false);
expect(dispatchKey('f', { metaKey: true })).toBe(false);
expect(dispatchKey('ArrowRight', { altKey: true })).toBe(false);
expect(handlers.togglePaused).not.toHaveBeenCalled();
expect(handlers.toggleFullscreen).not.toHaveBeenCalled();
expect(handlers.seekBy).not.toHaveBeenCalled();
});
it('still forwards Escape when modifier keys are held', () => {
expect(
dispatchKey('Escape', {
altKey: true,
ctrlKey: true,
metaKey: true,
})
).toBe(false);
expect(handlers.onEscape).toHaveBeenCalledTimes(1);
});
it('ignores shortcuts when a text control is anywhere in the composed path', () => {
// Simulate a shadow-DOM retargeted event: target is a host element,
// but the real input is exposed via composedPath().
const host = document.createElement('div');
const input = document.createElement('input');
document.body.append(host);
const event = new KeyboardEvent('keydown', {
key: 'ArrowUp',
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, 'composedPath', {
value: () => [input, host, document.body, document],
});
Object.defineProperty(event, 'target', { value: host });
document.dispatchEvent(event);
expect(handlers.adjustVolume).not.toHaveBeenCalled();
host.remove();
});
it('routes shortcuts to the active instance and falls back after detach', () => {
const other = new ControlsShortcuts();
const otherHandlers = {
isAvailable: jest.fn(() => true),
canTogglePaused: jest.fn(() => true),
canSeek: jest.fn(() => true),
canAdjustVolume: jest.fn(() => true),
canToggleFullscreen: jest.fn(() => true),
onEscape: jest.fn(),
togglePaused: jest.fn(),
toggleFullscreen: jest.fn(),
seekBy: jest.fn(),
adjustVolume: jest.fn(),
toggleMute: jest.fn(),
};
other.attach(otherHandlers);
dispatchKey('k');
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
expect(otherHandlers.togglePaused).not.toHaveBeenCalled();
other.activate();
dispatchKey('k');
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
expect(otherHandlers.togglePaused).toHaveBeenCalledTimes(1);
dispatchKey('Escape');
expect(handlers.onEscape).toHaveBeenCalledTimes(1);
expect(otherHandlers.onEscape).toHaveBeenCalledTimes(1);
other.detach();
dispatchKey('k');
expect(handlers.togglePaused).toHaveBeenCalledTimes(2);
});
it('falls back to an available instance when the active one is unavailable', () => {
const other = new ControlsShortcuts();
const otherHandlers = {
isAvailable: jest.fn(() => true),
canTogglePaused: jest.fn(() => true),
canSeek: jest.fn(() => true),
canAdjustVolume: jest.fn(() => true),
canToggleFullscreen: jest.fn(() => true),
onEscape: jest.fn(),
togglePaused: jest.fn(),
toggleFullscreen: jest.fn(),
seekBy: jest.fn(),
adjustVolume: jest.fn(),
toggleMute: jest.fn(),
};
other.attach(otherHandlers);
handlers.isAvailable.mockReturnValue(false);
dispatchKey('k');
expect(handlers.togglePaused).not.toHaveBeenCalled();
expect(otherHandlers.togglePaused).toHaveBeenCalledTimes(1);
other.detach();
});
it('detaches its document listener', () => {
shortcuts.detach();
dispatchKey('k');
expect(handlers.togglePaused).not.toHaveBeenCalled();
});
});
function dispatchKey(key: string, init: KeyboardEventInit = {}): boolean {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...init,
});
document.dispatchEvent(event);
return event.defaultPrevented;
}
@@ -0,0 +1,203 @@
export interface ControlsShortcutHandlers {
isAvailable: () => boolean;
canTogglePaused: () => boolean;
canSeek: () => boolean;
canAdjustVolume: () => boolean;
canToggleFullscreen: () => boolean;
onEscape: () => void;
togglePaused: () => void;
toggleFullscreen: () => void;
seekBy: (deltaSeconds: number) => void;
adjustVolume: (delta: number) => void;
toggleMute: () => void;
}
const INTERACTIVE_SELECTOR = [
'a[href]',
'button',
'input',
'select',
'summary',
'textarea',
'[contenteditable]:not([contenteditable="false"])',
'[role="button"]',
'[role="checkbox"]',
'[role="combobox"]',
'[role="link"]',
'[role="listbox"]',
'[role^="menuitem"]',
'[role="option"]',
'[role="radio"]',
'[role="slider"]',
'[role="switch"]',
'[role="textbox"]',
].join(',');
export class ControlsShortcuts {
private static readonly instances = new Set<ControlsShortcuts>();
private static active: ControlsShortcuts | null = null;
private handlers: ControlsShortcutHandlers | null = null;
private readonly listener = (event: KeyboardEvent) => this.handle(event);
attach(handlers: ControlsShortcutHandlers): void {
this.handlers = handlers;
ControlsShortcuts.instances.add(this);
ControlsShortcuts.active ??= this;
if (typeof document !== 'undefined') {
document.addEventListener('keydown', this.listener);
}
}
activate(): void {
if (this.handlers) {
ControlsShortcuts.active = this;
}
}
detach(): void {
if (typeof document !== 'undefined') {
document.removeEventListener('keydown', this.listener);
}
this.handlers = null;
ControlsShortcuts.instances.delete(this);
if (ControlsShortcuts.active === this) {
ControlsShortcuts.active = ControlsShortcuts.lastAttachedInstance();
}
}
private handle(event: KeyboardEvent): void {
const handlers = this.handlers;
if (!handlers) {
return;
}
// If another mounted controls instance already handled this keypress
// (it calls preventDefault below), don't double-execute it here.
if (event.defaultPrevented) {
return;
}
if (event.key === 'Escape') {
handlers.onEscape();
return;
}
if (
this.shouldIgnore(event) ||
ControlsShortcuts.resolvePlaybackOwner() !== this
) {
return;
}
switch (event.key) {
case ' ':
case 'k':
case 'K':
if (!handlers.canTogglePaused()) {
return;
}
event.preventDefault();
handlers.togglePaused();
return;
case 'f':
case 'F':
if (!handlers.canToggleFullscreen()) {
return;
}
event.preventDefault();
handlers.toggleFullscreen();
return;
case 'ArrowLeft':
if (!handlers.canSeek()) {
return;
}
event.preventDefault();
handlers.seekBy(-5);
return;
case 'ArrowRight':
if (!handlers.canSeek()) {
return;
}
event.preventDefault();
handlers.seekBy(5);
return;
case 'ArrowUp':
if (!handlers.canAdjustVolume()) {
return;
}
event.preventDefault();
handlers.adjustVolume(0.05);
return;
case 'ArrowDown':
if (!handlers.canAdjustVolume()) {
return;
}
event.preventDefault();
handlers.adjustVolume(-0.05);
return;
case 'm':
case 'M':
if (!handlers.canAdjustVolume()) {
return;
}
event.preventDefault();
handlers.toggleMute();
return;
}
}
/**
* Ignore modified playback keys so app/native shortcuts retain ownership,
* and ignore events originating from an interactive control anywhere in
* the composed path. Escape is handled before this check so it can still
* close controls popovers while a modifier key is held.
*/
private shouldIgnore(event: KeyboardEvent): boolean {
if (event.metaKey || event.ctrlKey || event.altKey) {
return true;
}
const path =
typeof event.composedPath === 'function'
? event.composedPath()
: [];
const nodes = path.length > 0 ? path : [event.target];
return nodes.some((node) => {
const element = node as HTMLElement | null;
if (!element || typeof element.tagName !== 'string') {
return false;
}
if (
typeof element.matches === 'function' &&
element.matches(INTERACTIVE_SELECTOR)
) {
return true;
}
return element.isContentEditable === true;
});
}
private static resolvePlaybackOwner(): ControlsShortcuts | null {
if (this.active?.handlers?.isAvailable()) {
return this.active;
}
let fallback: ControlsShortcuts | null = null;
for (const instance of this.instances) {
if (instance.handlers?.isAvailable()) {
fallback = instance;
}
}
this.active = fallback;
return fallback;
}
private static lastAttachedInstance(): ControlsShortcuts | null {
let last: ControlsShortcuts | null = null;
for (const instance of this.instances) {
last = instance;
}
return last;
}
}
@@ -0,0 +1,179 @@
import { ControlsSurface } from './controls-surface';
describe('ControlsSurface', () => {
let reveal: jest.Mock;
let toggleFullscreen: jest.Mock;
let closePopovers: jest.Mock;
let togglePlay: jest.Mock;
let canTogglePlay: boolean;
let menuOpen: boolean;
let surface: ControlsSurface;
let element: HTMLElement;
beforeEach(() => {
reveal = jest.fn();
toggleFullscreen = jest.fn();
closePopovers = jest.fn();
togglePlay = jest.fn();
canTogglePlay = true;
menuOpen = false;
element = document.createElement('div');
document.body.appendChild(element);
surface = new ControlsSurface({
reveal,
toggleFullscreen,
closePopovers,
togglePlay,
canTogglePlay: () => canTogglePlay,
isMenuOpen: () => menuOpen,
});
});
afterEach(() => {
surface.dispose();
element.remove();
jest.useRealTimers();
});
it('reveals on pointer activity over the surface', () => {
surface.attachSurface(element);
element.dispatchEvent(new MouseEvent('pointermove', { bubbles: true }));
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(reveal).toHaveBeenCalledTimes(2);
});
it('toggles fullscreen on a double-click outside interactive elements', () => {
surface.attachSurface(element);
element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
expect(toggleFullscreen).toHaveBeenCalledTimes(1);
});
it('ignores double-clicks on buttons/inputs/sliders', () => {
const button = document.createElement('button');
element.appendChild(button);
surface.attachSurface(element);
button.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
expect(toggleFullscreen).not.toHaveBeenCalled();
});
it('toggles play on a single click after the defer delay', () => {
jest.useFakeTimers();
surface.attachSurface(element);
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(togglePlay).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(togglePlay).toHaveBeenCalledTimes(1);
});
it('does not queue play while toggling is unavailable', () => {
jest.useFakeTimers();
canTogglePlay = false;
surface.attachSurface(element);
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
canTogglePlay = true;
jest.advanceTimersByTime(300);
expect(togglePlay).not.toHaveBeenCalled();
});
it('does not toggle play on a double-click, only fullscreens', () => {
jest.useFakeTimers();
surface.attachSurface(element);
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
jest.advanceTimersByTime(300);
expect(togglePlay).not.toHaveBeenCalled();
expect(toggleFullscreen).toHaveBeenCalledTimes(1);
});
it('does not toggle play on a click on a button', () => {
jest.useFakeTimers();
const button = document.createElement('button');
element.appendChild(button);
surface.attachSurface(element);
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.advanceTimersByTime(300);
expect(togglePlay).not.toHaveBeenCalled();
});
it('ignores clicks and double-clicks from the controls root', () => {
jest.useFakeTimers();
surface.dispose();
const controlsRoot = document.createElement('div');
const timeLabel = document.createElement('span');
controlsRoot.appendChild(timeLabel);
element.appendChild(controlsRoot);
surface = new ControlsSurface(
{
reveal,
toggleFullscreen,
closePopovers,
togglePlay,
canTogglePlay: () => canTogglePlay,
isMenuOpen: () => menuOpen,
},
controlsRoot
);
surface.attachSurface(element);
timeLabel.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.advanceTimersByTime(300);
expect(togglePlay).not.toHaveBeenCalled();
timeLabel.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
expect(toggleFullscreen).not.toHaveBeenCalled();
});
it('closes an open menu on click instead of toggling play', () => {
jest.useFakeTimers();
menuOpen = true;
surface.attachSurface(element);
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.advanceTimersByTime(300);
expect(togglePlay).not.toHaveBeenCalled();
expect(closePopovers).toHaveBeenCalledTimes(1);
});
it('closes popovers on an outside pointer-down', () => {
surface.attachSurface(element);
const outside = document.createElement('div');
document.body.appendChild(outside);
outside.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
expect(closePopovers).toHaveBeenCalledTimes(1);
outside.remove();
});
it('does not close popovers when the pointer-down is inside the surface', () => {
surface.attachSurface(element);
element.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
expect(closePopovers).not.toHaveBeenCalled();
});
it('detaches the previous surface listeners when rebinding', () => {
const first = document.createElement('div');
surface.attachSurface(first);
surface.attachSurface(element);
first.dispatchEvent(new MouseEvent('pointermove', { bubbles: true }));
expect(reveal).not.toHaveBeenCalled();
});
it('cancels a pending click-to-play action when rebinding', () => {
jest.useFakeTimers();
const first = document.createElement('div');
surface.attachSurface(first);
first.dispatchEvent(new MouseEvent('click', { bubbles: true }));
surface.attachSurface(element);
jest.advanceTimersByTime(300);
expect(togglePlay).not.toHaveBeenCalled();
});
it('stops reacting after dispose', () => {
surface.attachSurface(element);
surface.dispose();
element.dispatchEvent(new MouseEvent('pointermove', { bubbles: true }));
expect(reveal).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,161 @@
export interface ControlsSurfaceHandlers {
/** Reveal the controls (pointer move / enter / click on the surface). */
reveal: () => void;
/** Toggle fullscreen on an unhandled double-click. */
toggleFullscreen: () => void;
/** Close popovers on an outside pointer-down. */
closePopovers: () => void;
/**
* Toggle play/pause on a single click on the viewport (optional). When
* provided, a single click on a non-interactive area of the surface toggles
* playback after a short delay so a double-click (fullscreen) can cancel it.
*/
togglePlay?: () => void;
/** Whether a viewport click may currently queue a play/pause toggle. */
canTogglePlay?: () => boolean;
/** Whether a popover/menu is currently open (guards click-to-pause). */
isMenuOpen?: () => boolean;
}
/**
* The pause is deferred so a double-click (fullscreen) can cancel it before it
* runs. Mirrors the embedded-MPV viewport click behavior.
*/
const VIEWPORT_CLICK_PAUSE_DELAY_MS = 250;
const INTERACTIVE_SELECTOR = 'button, input, [role="slider"]';
/**
* Owns the surface + document interaction wiring for the controls: reveal on
* pointer activity, click-to-pause on the viewport, fullscreen on double-click,
* and popover dismissal on an outside pointer-down. The component binds
* {@link attachSurface} from an effect and can provide a separate inside root
* for controls rendered as a sibling of the playback surface.
*/
export class ControlsSurface {
private surface: HTMLElement | null = null;
private surfaceCleanup: (() => void) | null = null;
private clickPauseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly onDocumentPointerDown = (event: PointerEvent) => {
const path = event.composedPath();
if (
!this.surface ||
path.includes(this.surface) ||
(this.insideRoot !== null && path.includes(this.insideRoot))
) {
return;
}
this.handlers.closePopovers();
};
constructor(
private readonly handlers: ControlsSurfaceHandlers,
/** Additional root whose descendants count as inside for dismissal. */
private readonly insideRoot: HTMLElement | null = null
) {
if (typeof document !== 'undefined') {
document.addEventListener(
'pointerdown',
this.onDocumentPointerDown
);
}
}
/** Rebind the per-surface listeners; returns a cleanup for the effect. */
attachSurface(surface: HTMLElement | null): () => void {
this.clearClickPauseTimer();
this.surfaceCleanup?.();
this.surfaceCleanup = null;
this.surface = surface;
if (!surface) {
return () => undefined;
}
const reveal = () => this.handlers.reveal();
const click = (event: MouseEvent) => this.onClick(event);
const dblclick = (event: MouseEvent) => this.onDblClick(event);
surface.addEventListener('pointermove', reveal, { passive: true });
surface.addEventListener('pointerenter', reveal);
surface.addEventListener('click', click);
surface.addEventListener('dblclick', dblclick);
this.surfaceCleanup = () => {
surface.removeEventListener('pointermove', reveal);
surface.removeEventListener('pointerenter', reveal);
surface.removeEventListener('click', click);
surface.removeEventListener('dblclick', dblclick);
};
return () => this.surfaceCleanup?.();
}
dispose(): void {
this.clearClickPauseTimer();
this.surfaceCleanup?.();
this.surfaceCleanup = null;
if (typeof document !== 'undefined') {
document.removeEventListener(
'pointerdown',
this.onDocumentPointerDown
);
}
}
private onClick(event: MouseEvent): void {
// Always reveal the controls on a click on the surface.
this.handlers.reveal();
if (this.isInsideRoot(event)) {
return;
}
if (!this.handlers.togglePlay) {
return;
}
const target = event.target as HTMLElement | null;
if (target?.closest(INTERACTIVE_SELECTOR)) {
return;
}
// A click while a menu is open dismisses it instead of toggling.
if (this.handlers.isMenuOpen?.()) {
this.handlers.closePopovers();
return;
}
if (this.handlers.canTogglePlay?.() === false) {
return;
}
this.clearClickPauseTimer();
this.clickPauseTimer = setTimeout(() => {
this.clickPauseTimer = null;
this.handlers.togglePlay?.();
}, VIEWPORT_CLICK_PAUSE_DELAY_MS);
}
private onDblClick(event: MouseEvent): void {
// Cancel a pending single-click pause so a dblclick only fullscreens.
this.clearClickPauseTimer();
if (this.isInsideRoot(event)) {
return;
}
const target = event.target as HTMLElement | null;
if (target?.closest(INTERACTIVE_SELECTOR)) {
return;
}
this.handlers.toggleFullscreen();
}
private isInsideRoot(event: MouseEvent): boolean {
if (!this.insideRoot) {
return false;
}
const path = event.composedPath();
const target = event.target as Node | null;
return (
path.includes(this.insideRoot) ||
(target !== null && this.insideRoot.contains(target))
);
}
private clearClickPauseTimer(): void {
if (this.clickPauseTimer !== null) {
clearTimeout(this.clickPauseTimer);
this.clickPauseTimer = null;
}
}
}
@@ -0,0 +1,110 @@
import { Signal, computed } from '@angular/core';
import { formatTime, volumeIcon } from './controls-format.utils';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
} from './player-controls.model';
export interface ControlsViewModelDeps {
state: Signal<PlayerControlsState>;
capabilities: Signal<PlayerControlsCapabilities>;
volume: Signal<number>;
isFullscreen: Signal<boolean>;
canFullscreenNative: () => boolean;
showControls: Signal<boolean>;
/** Whether the auto-hide layer currently wants the bar visible. */
autoHideVisible: Signal<boolean>;
anyMenuOpen: Signal<boolean>;
}
/**
* Pure, framework-light bundle of the controls' derived presentation signals.
* Keeps the component lean: it derives status flags, track availability,
* recording status text, and the volume/fullscreen labels straight from the
* controller state + capabilities. The component still owns the auto-hide
* visibility (which depends on transient state) and binds these by name.
*/
export function createControlsViewModel(deps: ControlsViewModelDeps) {
const { state, capabilities, volume, isFullscreen } = deps;
const isLoading = computed(() => state().status === 'loading');
const isPaused = computed(() => {
const status = state().status;
return (
status === 'paused' ||
status === 'idle' ||
status === 'ended' ||
status === 'error'
);
});
const isPlaying = computed(() => state().status === 'playing');
const canTogglePlay = computed(() => {
const status = state().status;
return status !== 'loading' && status !== 'error';
});
const hasAudioTracks = computed(
() => capabilities().audioTracks && state().audioTracks.length > 1
);
const hasSubtitleTracks = computed(
() => capabilities().subtitles && state().subtitleTracks.length > 0
);
const canRecord = computed(
() =>
capabilities().recording &&
state().isLive &&
state().status !== 'error'
);
const isRecording = computed(() => state().recording.active);
const recordingStatusText = computed(() => {
if (!capabilities().recording) {
return '';
}
const recording = state().recording;
if (recording.active) {
return `REC ${formatTime(recording.elapsedSeconds)}`;
}
return recording.message;
});
const volumeIconName = computed(() => volumeIcon(volume()));
const canFullscreen = computed(
() => capabilities().fullscreen && deps.canFullscreenNative()
);
const controlsAreVisible = computed(() => {
if (!deps.showControls()) {
return false;
}
return (
deps.autoHideVisible() ||
isLoading() ||
isPaused() ||
deps.anyMenuOpen() ||
Boolean(state().statusMessage)
);
});
const hideCursor = computed(
() =>
deps.showControls() &&
isFullscreen() &&
isPlaying() &&
!controlsAreVisible()
);
return {
isLoading,
isPaused,
isPlaying,
canTogglePlay,
hasAudioTracks,
hasSubtitleTracks,
canRecord,
isRecording,
recordingStatusText,
volumeIcon: volumeIconName,
canFullscreen,
controlsAreVisible,
hideCursor,
};
}
@@ -0,0 +1,66 @@
import { ControlsVisibility } from './controls-visibility';
describe('ControlsVisibility', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('reveals and auto-hides when the canHide predicate allows it', () => {
const visibility = new ControlsVisibility(() => true, 1000);
visibility.reveal();
expect(visibility.visible()).toBe(true);
jest.advanceTimersByTime(1000);
expect(visibility.visible()).toBe(false);
});
it('stays visible while canHide returns false', () => {
const visibility = new ControlsVisibility(() => false, 1000);
visibility.reveal();
jest.advanceTimersByTime(1000);
expect(visibility.visible()).toBe(true);
});
it('does not schedule a hide when reveal is called with scheduleHide:false', () => {
const visibility = new ControlsVisibility(() => true, 1000);
visibility.reveal({ scheduleHide: false });
jest.advanceTimersByTime(1000);
expect(visibility.visible()).toBe(true);
});
it('starts a full hide delay after leaving a pinned state', () => {
let canHide = true;
const visibility = new ControlsVisibility(() => canHide, 1000);
visibility.reveal();
jest.advanceTimersByTime(400);
canHide = false;
visibility.reveal({ scheduleHide: false });
jest.advanceTimersByTime(100);
canHide = true;
visibility.scheduleHide();
jest.advanceTimersByTime(999);
expect(visibility.visible()).toBe(true);
jest.advanceTimersByTime(1);
expect(visibility.visible()).toBe(false);
});
it('clears a pending hide timer on dispose', () => {
const visibility = new ControlsVisibility(() => true, 1000);
visibility.reveal();
visibility.dispose();
jest.advanceTimersByTime(1000);
expect(visibility.visible()).toBe(true);
});
});
@@ -0,0 +1,62 @@
import { signal } from '@angular/core';
export const HIDE_CONTROLS_DELAY_MS = 2500;
export interface RevealOptions {
/** When false, reveals and clears any pending auto-hide without rescheduling. */
scheduleHide?: boolean;
}
/**
* Small framework-light helper that owns the controls reveal/auto-hide
* lifecycle. The controls become visible on user interaction and auto-hide
* after a delay, but only when the `canHide` predicate permits it (e.g. only
* while playing, no menu open, no status text showing).
*/
export class ControlsVisibility {
readonly visible = signal(true);
private hideTimer: number | null = null;
constructor(
private readonly canHide: () => boolean,
private readonly delayMs = HIDE_CONTROLS_DELAY_MS
) {}
reveal(options: RevealOptions = {}): void {
const scheduleHide = options.scheduleHide ?? true;
this.visible.set(true);
this.clear();
if (scheduleHide) {
this.scheduleHide();
}
}
scheduleHide(): void {
if (!this.canHide()) {
this.clear();
return;
}
if (!this.visible() || this.hideTimer !== null) {
return;
}
this.hideTimer = window.setTimeout(() => {
this.hideTimer = null;
if (this.canHide()) {
this.visible.set(false);
}
}, this.delayMs);
}
clear(): void {
if (this.hideTimer === null) {
return;
}
window.clearTimeout(this.hideTimer);
this.hideTimer = null;
}
dispose(): void {
this.clear();
}
}
@@ -0,0 +1,130 @@
import { ControlsVolume } from './controls-volume';
import type { PlayerController } from './player-controls.model';
describe('ControlsVolume', () => {
let applied: number[];
let flashes: Array<{ icon: string; label: string }>;
let openCount: number;
let closeCount: number;
let volume: ControlsVolume;
beforeEach(() => {
localStorage.clear();
applied = [];
flashes = [];
openCount = 0;
closeCount = 0;
volume = new ControlsVolume({
apply: (value) => applied.push(value),
flash: (icon, label) => flashes.push({ icon, label }),
mutedLabel: () => 'Stumm',
openPopover: () => (openCount += 1),
closePopover: () => (closeCount += 1),
});
});
afterEach(() => {
volume.dispose();
jest.useRealTimers();
});
it('set updates the signal, persists, and applies', () => {
volume.set(0.4);
expect(volume.value()).toBe(0.4);
expect(localStorage.getItem('volume')).toBe('0.4');
expect(applied).toEqual([0.4]);
});
it('clamps set values to [0, 1]', () => {
volume.set(2);
expect(volume.value()).toBe(1);
volume.set(-1);
expect(volume.value()).toBe(0);
});
it('adjust changes value and flashes percentage', () => {
volume.set(0.5);
flashes = [];
volume.adjust(0.1);
expect(volume.value()).toBeCloseTo(0.6);
expect(flashes.at(-1)?.label).toBe('60%');
});
it('toggleMute stores and restores the previous volume', () => {
volume.set(0.8);
volume.toggleMute();
expect(volume.value()).toBe(0);
expect(flashes.at(-1)).toEqual({ icon: 'volume_off', label: 'Stumm' });
volume.toggleMute();
expect(volume.value()).toBe(0.8);
});
it('toggleMute restores a default when nothing was muted from', () => {
volume.set(0);
flashes = [];
volume.toggleMute();
expect(volume.value()).toBe(0.5);
});
it('reconcile sets the value without persisting or applying', () => {
volume.reconcile(0.25);
expect(volume.value()).toBe(0.25);
expect(applied).toEqual([]);
expect(localStorage.getItem('volume')).toBeNull();
});
it('uses the first controller snapshot when no volume is persisted', () => {
const controller = {} as PlayerController;
volume.initializeController(controller, 0.4);
expect(volume.value()).toBe(0.4);
expect(applied).toEqual([]);
});
it('applies persisted volume before accepting a later controller snapshot', () => {
const controller = {} as PlayerController;
volume.set(0.3);
applied = [];
volume.initializeController(controller, 1);
volume.reconcileController(controller, 1);
expect(applied).toEqual([0.3]);
expect(volume.value()).toBe(0.3);
volume.reconcileController(controller, 0.5);
expect(volume.value()).toBe(0.5);
});
it('reapplies persisted volume after a controller capability epoch resets', () => {
const controller = {} as PlayerController;
volume.set(0.3);
applied = [];
volume.initializeController(controller, 1);
volume.deactivateController(controller);
volume.initializeController(controller, 1);
expect(applied).toEqual([0.3, 0.3]);
expect(volume.value()).toBe(0.3);
});
it('hoverEnter opens the popover and cancels any pending close', () => {
jest.useFakeTimers();
volume.hoverLeave();
volume.hoverEnter();
jest.runAllTimers();
expect(openCount).toBe(1);
expect(closeCount).toBe(0);
});
it('hoverLeave closes the popover after a delay', () => {
jest.useFakeTimers();
volume.hoverLeave();
expect(closeCount).toBe(0);
jest.runAllTimers();
expect(closeCount).toBe(1);
});
});
@@ -0,0 +1,148 @@
import { signal } from '@angular/core';
import {
persistVolume,
readStoredVolume,
volumeIcon,
} from './controls-format.utils';
import type { PlayerController } from './player-controls.model';
const VOLUME_POPOVER_CLOSE_DELAY_MS = 220;
export interface ControlsVolumeDeps {
/** Apply the volume to the underlying player (command). */
apply: (value: number) => void;
/** Flash transient feedback (icon + label). */
flash: (icon: string, label: string) => void;
/** Resolve the translated label used when muting. */
mutedLabel: () => string;
/** Open the hover popover. */
openPopover: () => void;
/** Close the hover popover. */
closePopover: () => void;
}
/**
* Owns the optimistic volume value, mute/unmute memory, and the hover-popover
* close timer. `set` is the single mutation point: it updates the optimistic
* value, persists it, and applies it to the player. The component still calls
* `reveal()` around these and binds the slider to `value`.
*/
export class ControlsVolume {
readonly value = signal(readStoredVolume());
private mutedVolume = 0;
private closeTimer: number | null = null;
private readonly initializedControllers = new WeakSet<PlayerController>();
private readonly pendingInitialSnapshots = new WeakMap<
PlayerController,
number
>();
constructor(private readonly deps: ControlsVolumeDeps) {}
set(value: number): void {
const clamped = Math.max(0, Math.min(1, value));
this.value.set(clamped);
persistVolume(clamped);
this.deps.apply(clamped);
}
hasInitializedController(controller: PlayerController): boolean {
return this.initializedControllers.has(controller);
}
beginCapabilityEpoch(
controller: PlayerController,
enabled: boolean
): boolean {
if (!enabled) {
this.deactivateController(controller);
return false;
}
return !this.hasInitializedController(controller);
}
deactivateController(controller: PlayerController): void {
this.initializedControllers.delete(controller);
this.pendingInitialSnapshots.delete(controller);
}
initializeController(controller: PlayerController, snapshot: number): void {
if (this.initializedControllers.has(controller)) {
return;
}
this.initializedControllers.add(controller);
if (localStorage.getItem('volume') !== null) {
this.deps.apply(this.value());
this.pendingInitialSnapshots.set(controller, snapshot);
} else {
this.reconcile(snapshot);
}
}
reconcileController(controller: PlayerController, snapshot: number): void {
if (!this.initializedControllers.has(controller)) {
return;
}
if (this.pendingInitialSnapshots.get(controller) === snapshot) {
this.pendingInitialSnapshots.delete(controller);
return;
}
this.pendingInitialSnapshots.delete(controller);
this.reconcile(snapshot);
}
adjust(delta: number): void {
const next = this.value() + delta;
this.set(next);
this.deps.flash(
volumeIcon(this.value()),
`${Math.round(this.value() * 100)}%`
);
}
toggleMute(): void {
const current = this.value();
if (current > 0) {
this.mutedVolume = current;
this.set(0);
this.deps.flash('volume_off', this.deps.mutedLabel());
} else {
const restored = this.mutedVolume || 0.5;
this.set(restored);
this.deps.flash(
volumeIcon(restored),
`${Math.round(restored * 100)}%`
);
}
}
/** Reconcile the optimistic value with the authoritative player state. */
reconcile(value: number): void {
this.value.set(value);
}
hoverEnter(): void {
this.clearTimer();
this.deps.openPopover();
}
hoverLeave(): void {
this.clearTimer();
this.closeTimer = window.setTimeout(() => {
this.deps.closePopover();
this.closeTimer = null;
}, VOLUME_POPOVER_CLOSE_DELAY_MS);
}
dispose(): void {
this.clearTimer();
}
private clearTimer(): void {
if (this.closeTimer !== null) {
clearTimeout(this.closeTimer);
this.closeTimer = null;
}
}
}
@@ -0,0 +1,15 @@
export * from './player-controls.model';
export * from './player-controls-defaults';
export * from './player-controls.component';
export * from './controls-menu-state';
export * from './controls-feedback';
export * from './controls-fullscreen';
export * from './controls-shortcuts';
export * from './controls-surface';
export * from './controls-view-model';
export * from './controls-visibility';
export * from './controls-volume';
export * from './controls-format.utils';
export * from './web-player-controls.flag';
export * from './web-video-controls.adapter';
export * from './web-video-controls.host';
@@ -0,0 +1,57 @@
import type {
PlayerControlsCapabilities,
PlayerControlsState,
PlayerPreset,
} from './player-controls.model';
export const DEFAULT_PLAYER_CAPABILITIES: PlayerControlsCapabilities = {
seek: false,
volume: false,
audioTracks: false,
subtitles: false,
playbackSpeed: false,
aspectRatio: false,
recording: false,
fullscreen: false,
seriesNavigation: false,
};
export const DEFAULT_SPEED_PRESETS: ReadonlyArray<PlayerPreset<number>> = [
{ value: 0.5, label: '0.5×' },
{ value: 0.75, label: '0.75×' },
{ value: 1, label: '1×' },
{ value: 1.25, label: '1.25×' },
{ value: 1.5, label: '1.5×' },
{ value: 2, label: '2×' },
];
export const DEFAULT_ASPECT_PRESETS: ReadonlyArray<PlayerPreset<string>> = [
{ value: 'no', label: 'Default' },
{ value: '16:9', label: '16:9' },
{ value: '4:3', label: '4:3' },
{ value: '21:9', label: '21:9' },
{ value: '2.35:1', label: '2.35:1' },
];
export function createEmptyControlsState(): PlayerControlsState {
return {
status: 'idle',
statusMessage: '',
stalled: false,
positionSeconds: 0,
durationSeconds: null,
isLive: false,
canSeek: false,
volume: 1,
audioTracks: [],
subtitleTracks: [],
subtitlesEnabled: false,
playbackSpeed: 1,
speedPresets: DEFAULT_SPEED_PRESETS,
aspectRatio: 'no',
aspectPresets: DEFAULT_ASPECT_PRESETS,
recording: { active: false, elapsedSeconds: 0, message: null },
canPreviousEpisode: false,
canNextEpisode: false,
};
}
@@ -0,0 +1,272 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
interface FakeController {
controller: PlayerController;
capabilities: WritableSignal<PlayerControlsCapabilities>;
state: WritableSignal<PlayerControlsState>;
commands: jest.Mocked<PlayerControlsCommands>;
}
function createFakeController(): FakeController {
const capabilities = signal({ ...DEFAULT_PLAYER_CAPABILITIES });
const state = signal(createEmptyControlsState());
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
};
return {
controller: { capabilities, state, commands },
capabilities,
state,
commands,
};
}
describe('PlayerControlsComponent capability contract', () => {
const fixtures: ComponentFixture<PlayerControlsComponent>[] = [];
const surfaces: HTMLElement[] = [];
beforeEach(async () => {
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
});
afterEach(() => {
for (const fixture of fixtures.splice(0)) {
fixture.destroy();
}
for (const surface of surfaces.splice(0)) {
surface.remove();
}
});
function createControls(
fake: FakeController,
surface: HTMLElement | null = null
): ComponentFixture<PlayerControlsComponent> {
const fixture = TestBed.createComponent(PlayerControlsComponent);
fixture.componentRef.setInput('controller', fake.controller);
if (surface) {
fixture.componentRef.setInput('playerSurface', surface);
}
fixture.detectChanges();
fixtures.push(fixture);
return fixture;
}
function createSurface(): HTMLElement {
const surface = document.createElement('div');
document.body.appendChild(surface);
surfaces.push(surface);
return surface;
}
function pressKey(key: string): boolean {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
return event.defaultPrevented;
}
it('routes shortcuts to the player the user interacted with most recently', () => {
const first = createFakeController();
const second = createFakeController();
createControls(first, createSurface());
const secondSurface = createSurface();
const secondFixture = createControls(second, secondSurface);
pressKey('k');
expect(first.commands.togglePlay).toHaveBeenCalledTimes(1);
expect(second.commands.togglePlay).not.toHaveBeenCalled();
secondSurface.dispatchEvent(new MouseEvent('pointermove'));
pressKey('k');
expect(first.commands.togglePlay).toHaveBeenCalledTimes(1);
expect(second.commands.togglePlay).toHaveBeenCalledTimes(1);
secondFixture.destroy();
fixtures.splice(fixtures.indexOf(secondFixture), 1);
pressKey('k');
expect(first.commands.togglePlay).toHaveBeenCalledTimes(2);
});
it('requires the volume capability for volume shortcuts', () => {
const fake = createFakeController();
const fixture = createControls(fake);
expect(pressKey('ArrowDown')).toBe(false);
expect(pressKey('m')).toBe(false);
expect(fake.commands.setVolume).not.toHaveBeenCalled();
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
volume: true,
});
fixture.detectChanges();
expect(pressKey('ArrowDown')).toBe(true);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95);
});
it('applies persisted volume before reconciling the first controller snapshot', () => {
localStorage.setItem('volume', '0.3');
const fake = createFakeController();
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
volume: true,
});
fake.state.set({
...createEmptyControlsState(),
volume: 1,
});
const fixture = createControls(fake);
expect(fake.commands.setVolume).toHaveBeenCalledTimes(1);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.3);
expect(fixture.componentInstance.displayVolume()).toBe(0.3);
});
it('requires both seek capability and seekable state for seek shortcuts', () => {
const fake = createFakeController();
const fixture = createControls(fake);
fake.state.set({
...createEmptyControlsState(),
canSeek: true,
durationSeconds: 600,
});
fixture.detectChanges();
expect(pressKey('ArrowRight')).toBe(false);
expect(fake.commands.seekBy).not.toHaveBeenCalled();
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
seek: true,
});
fake.state.set({
...createEmptyControlsState(),
canSeek: false,
durationSeconds: 600,
});
fixture.detectChanges();
expect(pressKey('ArrowRight')).toBe(false);
expect(fake.commands.seekBy).not.toHaveBeenCalled();
fake.state.set({
...createEmptyControlsState(),
canSeek: true,
durationSeconds: 600,
});
fixture.detectChanges();
expect(pressKey('ArrowRight')).toBe(true);
expect(fake.commands.seekBy).toHaveBeenCalledWith(5);
});
it('closes menus when their runtime availability disappears', () => {
const fake = createFakeController();
const fixture = createControls(fake);
const component = fixture.componentInstance;
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
audioTracks: true,
});
fake.state.set({
...createEmptyControlsState(),
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
});
fixture.detectChanges();
component.toggleMenu('audio');
expect(component.menus.audioOpen()).toBe(true);
fake.state.set({
...createEmptyControlsState(),
audioTracks: [{ id: 1, label: 'English', selected: true }],
});
fixture.detectChanges();
expect(component.anyMenuOpen()).toBe(false);
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
playbackSpeed: true,
});
fixture.detectChanges();
component.toggleMenu('speed');
expect(component.menus.speedOpen()).toBe(true);
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
fixture.detectChanges();
expect(component.anyMenuOpen()).toBe(false);
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
volume: true,
});
fixture.detectChanges();
component.onVolumeHoverEnter();
expect(component.menus.volumeOpen()).toBe(true);
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
expect(component.anyMenuOpen()).toBe(false);
});
it('hides the scrub slider without hiding live and recording status', () => {
const fake = createFakeController();
const fixture = createControls(fake);
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
recording: true,
});
fake.state.set({
...createEmptyControlsState(),
isLive: true,
recording: {
active: true,
elapsedSeconds: 12,
message: null,
},
});
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(
root.querySelector('.player-controls__timeline > input')
).toBeNull();
expect(
root.querySelector('.player-controls__live-badge')
).not.toBeNull();
expect(
root.querySelector('.player-controls__recording-status')
?.textContent
).toContain('REC 0:12');
});
});
@@ -0,0 +1,599 @@
@if (feedback.current(); as fb) {
<div
class="player-controls__feedback"
[attr.data-feedback-key]="fb.key"
aria-hidden="true"
>
<mat-icon>{{ fb.icon }}</mat-icon>
<span>{{ fb.label }}</span>
</div>
}
@if (showControls()) {
<div
#controlsBar
class="player-controls__bar"
[class.player-controls__bar--visible]="controlsAreVisible()"
(pointerenter)="onBarPointerEnter()"
(pointerleave)="onBarPointerLeave()"
(focusin)="onBarFocusIn()"
(focusout)="onBarFocusOut($event)"
>
<div class="player-controls__transport">
<button
mat-icon-button
type="button"
class="player-controls__button"
[disabled]="!canTogglePlay()"
(click)="togglePlay()"
[attr.aria-label]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
[matTooltip]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{ isPaused() ? 'play_arrow' : 'pause' }}</mat-icon>
</button>
@if (capabilities().seriesNavigation) {
<button
mat-icon-button
type="button"
class="player-controls__button"
data-test-id="player-controls-previous-episode"
[disabled]="isLoading() || !state().canPreviousEpisode"
(click)="requestPreviousEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="player-controls__button"
data-test-id="player-controls-next-episode"
[disabled]="isLoading() || !state().canNextEpisode"
(click)="requestNextEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
}
@if (capabilities().seek) {
<button
mat-icon-button
type="button"
class="player-controls__button"
[disabled]="!state().canSeek"
(click)="seekBy(-10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>replay_10</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="player-controls__button"
[disabled]="!state().canSeek"
(click)="seekBy(10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>forward_10</mat-icon>
</button>
}
</div>
<div class="player-controls__timeline">
@if (capabilities().seek) {
<input
class="player-controls__slider"
[style.--slider-progress.%]="timelineProgress()"
type="range"
min="0"
[max]="timelineDuration()"
[value]="timelineValue()"
[disabled]="!state().canSeek"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_POSITION' | translate
"
[attr.aria-valuetext]="formatTime(timelineValue())"
(input)="onTimelineInput($event)"
(change)="onTimelineCommit($event)"
/>
}
<div class="player-controls__time">
<span>{{ formatTime(timelineValue()) }}</span>
@if (state().canSeek) {
<span>{{ formatTime(timelineDuration()) }}</span>
} @else if (state().isLive) {
<span
class="player-controls__live-badge"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate
"
>
<span
class="player-controls__live-dot"
aria-hidden="true"
></span>
LIVE
</span>
} @else {
<span>--:--</span>
}
</div>
@if (recordingStatusText(); as recordingStatus) {
<div
class="player-controls__recording-status"
[class.player-controls__recording-status--active]="
isRecording()
"
[attr.aria-live]="isRecording() ? 'off' : 'polite'"
>
<mat-icon>{{
isRecording() ? 'fiber_manual_record' : 'check_circle'
}}</mat-icon>
<span>{{ recordingStatus }}</span>
</div>
}
</div>
<div class="player-controls__actions">
@if (capabilities().volume) {
<div
class="player-controls__popover-anchor"
(pointerenter)="onVolumeHoverEnter()"
(pointerleave)="onVolumeHoverLeave()"
(focusin)="onVolumeHoverEnter()"
(focusout)="onVolumeHoverLeave()"
(wheel)="onVolumeWheel($event)"
>
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="toggleMute()"
[attr.aria-label]="
(displayVolume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
[matTooltip]="
(displayVolume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
@if (menus.volumeOpen()) {
<div
class="player-controls__panel player-controls__volume-popover"
role="dialog"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.VOLUME' | translate
"
>
<input
class="player-controls__slider player-controls__slider--volume"
[style.--slider-progress.%]="
displayVolume() * 100
"
type="range"
min="0"
max="1"
step="0.01"
[value]="displayVolume()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.VOLUME_LABEL'
| translate
: { percent: volumePercent() }
"
[attr.aria-valuetext]="
'EMBEDDED_MPV.PLAYER.VOLUME_LABEL'
| translate
: { percent: volumePercent() }
"
(input)="onVolumeInput($event)"
/>
<span class="player-controls__volume-value">
{{
'EMBEDDED_MPV.PLAYER.VOLUME_LABEL'
| translate
: { percent: volumePercent() }
}}
</span>
</div>
}
</div>
}
@if (hasAudioTracks()) {
<div class="player-controls__popover-anchor">
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="toggleMenu('audio')"
[attr.aria-expanded]="menus.audioOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
matTooltipPosition="above"
>
<mat-icon>graphic_eq</mat-icon>
</button>
@if (menus.audioOpen()) {
<div
class="player-controls__panel player-controls__track-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
>
<div class="player-controls__menu-title">
{{
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
| translate
}}
</div>
<div class="player-controls__track-list">
@for (
track of state().audioTracks;
track track.id
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="player-controls__track"
[class.player-controls__track--selected]="
track.selected
"
(click)="
menuSelection.audioTrack(track.id)
"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
@if (hasSubtitleTracks()) {
<div class="player-controls__popover-anchor">
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="toggleMenu('subtitle')"
[attr.aria-expanded]="menus.subtitleOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
state().subtitlesEnabled
? 'subtitles'
: 'subtitles_off'
}}</mat-icon>
</button>
@if (menus.subtitleOpen()) {
<div
class="player-controls__panel player-controls__track-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
>
<div class="player-controls__menu-title">
{{
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
}}
</div>
<div class="player-controls__track-list">
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
!state().subtitlesEnabled
"
class="player-controls__track"
[class.player-controls__track--selected]="
!state().subtitlesEnabled
"
(click)="menuSelection.subtitleTrack(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
| translate
}}</span>
@if (!state().subtitlesEnabled) {
<mat-icon>check</mat-icon>
}
</button>
@for (
track of state().subtitleTracks;
track track.id
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="player-controls__track"
[class.player-controls__track--selected]="
track.selected
"
(click)="
menuSelection.subtitleTrack(
track.id
)
"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
@if (capabilities().playbackSpeed) {
<div class="player-controls__popover-anchor">
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="toggleMenu('speed')"
[attr.aria-expanded]="menus.speedOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP'
| translate
: {
speed: speedLabel(
state().playbackSpeed
),
}
"
matTooltipPosition="above"
>
<mat-icon>speed</mat-icon>
</button>
@if (menus.speedOpen()) {
<div
class="player-controls__panel player-controls__track-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
"
>
<div class="player-controls__menu-title">
{{
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate
}}
</div>
<div class="player-controls__track-list">
@for (
preset of state().speedPresets;
track preset.value
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value ===
state().playbackSpeed
"
class="player-controls__track"
[class.player-controls__track--selected]="
preset.value ===
state().playbackSpeed
"
(click)="
menuSelection.speed(preset.value)
"
>
<span>{{ preset.label }}</span>
@if (
preset.value ===
state().playbackSpeed
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
@if (capabilities().aspectRatio) {
<div class="player-controls__popover-anchor">
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="toggleMenu('aspect')"
[attr.aria-expanded]="menus.aspectOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
"
matTooltipPosition="above"
>
<mat-icon>aspect_ratio</mat-icon>
</button>
@if (menus.aspectOpen()) {
<div
class="player-controls__panel player-controls__track-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
"
>
<div class="player-controls__menu-title">
{{
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate
}}
</div>
<div class="player-controls__track-list">
@for (
preset of state().aspectPresets;
track preset.value
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value === state().aspectRatio
"
class="player-controls__track"
[class.player-controls__track--selected]="
preset.value === state().aspectRatio
"
(click)="
menuSelection.aspect(preset.value)
"
>
<span>{{
preset.value === 'no'
? ('EMBEDDED_MPV.PLAYER.ASPECT_DEFAULT'
| translate)
: preset.label
}}</span>
@if (
preset.value === state().aspectRatio
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
@if (canRecord()) {
<button
mat-icon-button
type="button"
class="player-controls__button player-controls__record-button"
[class.player-controls__record-button--active]="
isRecording()
"
[disabled]="isLoading()"
(click)="toggleRecording()"
[attr.aria-pressed]="isRecording()"
[attr.aria-label]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
[matTooltip]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isRecording() ? 'stop_circle' : 'fiber_manual_record'
}}</mat-icon>
</button>
}
@if (capabilities().fullscreen) {
<button
mat-icon-button
type="button"
class="player-controls__button"
[disabled]="!canFullscreen()"
(click)="toggleFullscreen()"
[attr.aria-label]="
(isFullscreen()
? 'EMBEDDED_MPV.PLAYER.EXIT_FULLSCREEN'
: 'EMBEDDED_MPV.PLAYER.ENTER_FULLSCREEN'
) | translate
"
[matTooltip]="
(isFullscreen()
? 'EMBEDDED_MPV.PLAYER.EXIT_FULLSCREEN'
: 'EMBEDDED_MPV.PLAYER.ENTER_FULLSCREEN'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
}}</mat-icon>
</button>
}
</div>
</div>
}
@@ -0,0 +1,370 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
describe('PlayerControlsComponent interactions', () => {
let fixture: ComponentFixture<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
const queryAll = (selector: string) =>
Array.from(
fixture.nativeElement.querySelectorAll(selector)
) as HTMLElement[];
beforeEach(async () => {
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('controller', fake.controller);
fixture.detectChanges();
});
describe('timeline', () => {
it('ignores seekBy while the stream cannot seek', () => {
setState({ canSeek: false });
fixture.detectChanges();
component.seekBy(10);
expect(fake.commands.seekBy).not.toHaveBeenCalled();
expect(component.feedback.current()).toBeNull();
});
it('flashes seek feedback with a signed label', () => {
jest.useFakeTimers();
setCapabilities({ seek: true });
setState({ canSeek: true, durationSeconds: 600 });
fixture.detectChanges();
component.seekBy(-10);
fixture.detectChanges();
expect(fake.commands.seekBy).toHaveBeenCalledWith(-10);
const feedback = query('.player-controls__feedback');
expect(feedback?.textContent).toContain('replay_10');
expect(feedback?.textContent).toContain('-10s');
jest.advanceTimersByTime(800);
fixture.detectChanges();
expect(query('.player-controls__feedback')).toBeNull();
jest.useRealTimers();
});
});
describe('volume', () => {
beforeEach(() => {
setCapabilities({ volume: true });
fixture.detectChanges();
});
it('opens the popover on hover and applies slider input', () => {
component.onVolumeHoverEnter();
fixture.detectChanges();
const slider = query(
'.player-controls__slider--volume'
) as HTMLInputElement;
expect(slider).not.toBeNull();
slider.value = '0.3';
slider.dispatchEvent(new Event('input'));
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.3);
expect(component.displayVolume()).toBe(0.3);
});
it('closes the popover shortly after hover leave', () => {
jest.useFakeTimers();
component.onVolumeHoverEnter();
fixture.detectChanges();
expect(component.menus.volumeOpen()).toBe(true);
component.onVolumeHoverLeave();
jest.advanceTimersByTime(300);
fixture.detectChanges();
expect(component.menus.volumeOpen()).toBe(false);
jest.useRealTimers();
});
it('adjusts volume via wheel and flashes feedback', () => {
component.onVolumeWheel(
new WheelEvent('wheel', { deltaY: 100, cancelable: true })
);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95);
expect(component.feedback.current()?.label).toBe('95%');
component.onVolumeWheel(new WheelEvent('wheel', { deltaY: -100 }));
expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1);
});
it('mutes on click and restores the previous volume on unmute', () => {
const muteButton = query('[aria-label="EMBEDDED_MPV.PLAYER.MUTE"]');
muteButton?.click();
fixture.detectChanges();
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(component.displayVolume()).toBe(0);
expect(component.volumeIcon()).toBe('volume_off');
query('[aria-label="EMBEDDED_MPV.PLAYER.UNMUTE"]')?.click();
expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1);
expect(component.displayVolume()).toBe(1);
});
it('reconciles the optimistic volume with controller state', () => {
setState({ volume: 0.25 });
fixture.detectChanges();
expect(component.displayVolume()).toBe(0.25);
expect(component.volumePercent()).toBe(25);
});
it('keeps optimistic volume across capability and visibility changes', () => {
component.onVolumeWheel(new WheelEvent('wheel', { deltaY: 100 }));
expect(component.displayVolume()).toBe(0.95);
setState({ positionSeconds: 15, volume: 1 });
fixture.detectChanges();
expect(component.displayVolume()).toBe(0.95);
setCapabilities({ volume: true, playbackSpeed: true });
fixture.detectChanges();
expect(component.displayVolume()).toBe(0.95);
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
expect(component.displayVolume()).toBe(0.95);
});
it('reapplies persisted volume when the capability returns', () => {
component.onVolumeWheel(new WheelEvent('wheel', { deltaY: 100 }));
fake.commands.setVolume.mockClear();
setCapabilities({ volume: false });
setState({ volume: 1 });
fixture.detectChanges();
setCapabilities({ volume: true });
fixture.detectChanges();
expect(fake.commands.setVolume).toHaveBeenCalledTimes(1);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95);
expect(component.displayVolume()).toBe(0.95);
});
it('reconciles volume when the controller changes at the same value', () => {
component.onVolumeWheel(new WheelEvent('wheel', { deltaY: 100 }));
expect(component.displayVolume()).toBe(0.95);
localStorage.removeItem('volume');
const replacement = createFakeController();
replacement.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
volume: true,
});
fixture.componentRef.setInput('controller', replacement.controller);
fixture.detectChanges();
expect(component.displayVolume()).toBe(1);
});
});
describe('menu selections', () => {
it('applies a speed preset and closes the menu', () => {
setCapabilities({ playbackSpeed: true });
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED"]')?.click();
fixture.detectChanges();
expect(component.menus.speedOpen()).toBe(true);
const preset = queryAll('.player-controls__track').find((item) =>
item.textContent?.includes('1.5×')
);
preset?.click();
fixture.detectChanges();
expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5);
expect(component.menus.speedOpen()).toBe(false);
});
it('applies an aspect preset and closes the menu', () => {
setCapabilities({ aspectRatio: true });
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.ASPECT_RATIO"]')?.click();
fixture.detectChanges();
const preset = queryAll('.player-controls__track').find((item) =>
item.textContent?.includes('16:9')
);
preset?.click();
fixture.detectChanges();
expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(component.menus.aspectOpen()).toBe(false);
});
it('disables subtitles via the Off entry', () => {
setCapabilities({ subtitles: true });
setState({
subtitleTracks: [{ id: 1, label: 'English', selected: true }],
subtitlesEnabled: true,
});
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.SUBTITLES"]')?.click();
fixture.detectChanges();
queryAll('.player-controls__track')[0]?.click();
expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(-1);
expect(component.menus.subtitleOpen()).toBe(false);
});
it('opening one menu closes the others', () => {
setCapabilities({ playbackSpeed: true, aspectRatio: true });
fixture.detectChanges();
component.toggleMenu('speed');
component.toggleMenu('aspect');
expect(component.menus.speedOpen()).toBe(false);
expect(component.menus.aspectOpen()).toBe(true);
expect(component.anyMenuOpen()).toBe(true);
});
});
describe('recording', () => {
it('does nothing when recording is not available', () => {
component.toggleRecording();
expect(fake.commands.toggleRecording).not.toHaveBeenCalled();
});
it('toggles recording when capable and live', () => {
setCapabilities({ recording: true });
setState({ isLive: true });
fixture.detectChanges();
query('.player-controls__record-button')?.click();
expect(fake.commands.toggleRecording).toHaveBeenCalledTimes(1);
});
it('flashes feedback on recording start and stop transitions', () => {
const translate = TestBed.inject(TranslateService);
translate.setTranslation('de', {
EMBEDDED_MPV: {
PLAYER: {
RECORDING: 'Aufnahme',
RECORDING_SAVED: 'Aufnahme gespeichert',
},
},
});
translate.use('de');
setState({
isLive: true,
recording: { active: true, elapsedSeconds: 0, message: null },
});
fixture.detectChanges();
expect(component.feedback.current()?.label).toBe('Aufnahme');
setState({ isLive: true });
fixture.detectChanges();
expect(component.feedback.current()?.label).toBe(
'Aufnahme gespeichert'
);
});
});
describe('reveal/hide interplay', () => {
beforeEach(() => {
jest.useFakeTimers();
setState({ status: 'playing' });
fixture.detectChanges();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('keeps the controls visible while a menu is open, hides after close', () => {
setCapabilities({ playbackSpeed: true });
fixture.detectChanges();
component.toggleMenu('speed');
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
component.menuSelection.speed(1.25);
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(false);
});
it('stays visible while a status message is showing', () => {
setState({ status: 'playing', statusMessage: 'Buffering…' });
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
});
it('re-reveals hidden controls on reveal()', () => {
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(false);
component.reveal();
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
});
});
});
@@ -0,0 +1,469 @@
:host {
--pc-accent: #ffffff;
--pc-track: rgba(255, 255, 255, 0.3);
--pc-thumb-ring: rgba(255, 255, 255, 0.95);
--pc-panel-bg: rgba(18, 18, 20, 0.92);
--pc-panel-border: rgba(255, 255, 255, 0.16);
--pc-text: #ffffff;
--pc-text-dim: rgba(255, 255, 255, 0.82);
position: absolute;
inset: 0;
z-index: 2;
display: block;
// The control layer is transparent to the mouse so the video underneath
// stays interactive; only the bar/popovers opt back in to pointer events.
pointer-events: none;
color: var(--pc-text);
// Become a query container so popovers can size against the actual player
// box (the host fills the player via inset:0) instead of fixed pixels or
// the viewport — they grow to fit when there is room and shrink to scroll
// when the player is small, never spilling past its edges.
container-type: size;
container-name: player-controls;
}
:host(.player-controls-host--cursor-hidden) {
cursor: none;
}
.player-controls__feedback {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
color: #ffffff;
background: rgba(0, 0, 0, 0.65);
border-radius: 999px;
font-size: 0.95rem;
font-variant-numeric: tabular-nums;
pointer-events: none;
z-index: 3;
animation: player-controls-feedback 700ms ease-out forwards;
}
.player-controls__feedback mat-icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #ffffff;
}
@keyframes player-controls-feedback {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.92);
}
18% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
70% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1);
}
}
.player-controls__bar {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
align-content: end;
min-height: 64px;
// Generous top padding lets the scrim rise above the controls and fade out.
padding: 48px 16px 12px;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
// True-overlay scrim: opaque-ish black at the bottom fading to fully
// transparent at the top, so controls read clearly over full-bleed video.
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.45) 45%,
transparent 100%
);
transition:
opacity 180ms ease-out,
transform 200ms ease-out;
}
.player-controls__bar--visible {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.player-controls__transport {
display: flex;
align-items: center;
gap: 8px;
}
.player-controls__timeline {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.player-controls__time {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
color: var(--pc-text-dim);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.player-controls__recording-status {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: rgba(255, 255, 255, 0.78);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
.player-controls__recording-status mat-icon {
flex: 0 0 auto;
width: 16px;
height: 16px;
font-size: 16px;
color: #ffffff;
}
.player-controls__recording-status span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-controls__recording-status--active {
color: var(--mat-sys-error, #ff5252);
font-weight: 700;
}
.player-controls__recording-status--active mat-icon {
color: var(--mat-sys-error, #ff5252);
}
.player-controls__live-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 4px;
color: #ffffff;
background: var(--mat-sys-error, #e53935);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.player-controls__live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: player-controls-live-pulse 1.6s ease-in-out infinite;
}
@keyframes player-controls-live-pulse {
50% {
opacity: 0.35;
}
}
.player-controls__actions {
display: flex;
align-items: center;
gap: 8px;
}
.player-controls__popover-anchor {
position: relative;
display: flex;
}
.player-controls__record-button--active {
color: var(--mat-sys-error, #ff5252) !important;
}
// Scope to :host — the controls render directly under the component host
// (`.player-controls-host`); there is no `.player-controls` wrapper element, so
// selectors prefixed with `.player-controls ` never matched (which is why the
// icons were only white on :hover, the one rule scoped to `.player-controls__bar`).
:host :is(button[mat-icon-button]) {
// Material drives the icon colour through MDC tokens, not plain `color`,
// so force both — otherwise the icons render in the dim theme colour.
color: #ffffff;
--mdc-icon-button-icon-color: #ffffff;
--mat-icon-button-icon-color: #ffffff;
}
:host :is(button[mat-icon-button]) mat-icon {
color: #ffffff;
}
:host :is(button[mat-icon-button][disabled]) {
// Keep disabled affordances visibly muted.
--mdc-icon-button-icon-color: rgba(255, 255, 255, 0.38);
color: rgba(255, 255, 255, 0.38);
}
:host :is(button[mat-icon-button][disabled]) mat-icon {
color: rgba(255, 255, 255, 0.38);
}
:host .player-controls__bar :is(button[mat-icon-button]:hover) {
color: #ffffff;
background: rgba(255, 255, 255, 0.16);
}
.player-controls__slider {
--slider-progress: 0%;
width: 100%;
height: 18px;
appearance: none;
-webkit-appearance: none;
background: transparent;
cursor: pointer;
}
.player-controls__slider:focus-visible {
outline: 2px solid var(--pc-accent);
outline-offset: 4px;
border-radius: 4px;
}
.player-controls__slider::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--pc-accent) var(--slider-progress),
var(--pc-track) var(--slider-progress)
);
}
.player-controls__slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -5px;
border: 2px solid var(--pc-thumb-ring);
border-radius: 50%;
background: var(--pc-accent);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
transition: transform 120ms ease-out;
}
.player-controls__slider:hover::-webkit-slider-thumb,
.player-controls__slider:focus-visible::-webkit-slider-thumb {
transform: scale(1.18);
}
.player-controls__slider:disabled::-webkit-slider-runnable-track {
background: var(--pc-track);
}
.player-controls__slider:disabled::-webkit-slider-thumb {
background: rgba(255, 255, 255, 0.45);
border-color: rgba(255, 255, 255, 0.6);
box-shadow: none;
}
.player-controls__slider::-moz-range-track {
height: 4px;
border-radius: 999px;
background: var(--pc-track);
}
.player-controls__slider::-moz-range-progress {
height: 4px;
border-radius: 999px;
background: var(--pc-accent);
}
.player-controls__slider::-moz-range-thumb {
width: 14px;
height: 14px;
border: 2px solid var(--pc-thumb-ring);
border-radius: 50%;
background: var(--pc-accent);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
transition: transform 120ms ease-out;
}
.player-controls__slider:hover::-moz-range-thumb,
.player-controls__slider:focus-visible::-moz-range-thumb {
transform: scale(1.18);
}
.player-controls__slider:disabled::-moz-range-thumb {
background: rgba(255, 255, 255, 0.45);
border-color: rgba(255, 255, 255, 0.6);
box-shadow: none;
}
.player-controls__panel {
min-width: 0;
color: var(--pc-text);
background: var(--pc-panel-bg);
border: 1px solid var(--pc-panel-border);
border-radius: 14px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(18px);
}
.player-controls__volume-popover {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
display: grid;
grid-template-columns: 1fr;
gap: 4px;
// Comfortable fixed width for the slider; only clamp on players too narrow
// to fit it so the popover never spills past the player's edges.
width: 168px;
max-width: calc(100cqw - 24px);
padding: 12px 14px 10px;
z-index: 3;
}
.player-controls__volume-popover::before {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -8px;
height: 8px;
}
.player-controls__volume-popover .player-controls__slider--volume {
width: 100%;
}
.player-controls__volume-value {
color: var(--pc-text-dim);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
text-align: center;
white-space: nowrap;
}
.player-controls__track-popover {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
display: flex;
flex-direction: column;
min-width: 220px;
// Cap to the player box (minus a small gutter) so the panel never spills
// past the player's edges on small windows; falls back to the viewport
// where container units are unavailable.
max-width: min(420px, calc(100cqw - 24px), 80vw);
// Height grows to fit the list; only when it would exceed the room above
// the controls bar does the inner list start scrolling. 96px ≈ the controls
// bar height plus the 6px gap and a little breathing room.
max-height: min(calc(100cqh - 96px), 70vh);
padding: 10px 12px;
z-index: 3;
}
.player-controls__menu-title {
flex: 0 0 auto;
padding: 0 2px 6px;
color: rgba(255, 255, 255, 0.7);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.player-controls__track-list {
display: flex;
flex-direction: column;
gap: 4px;
// No fixed cap: the panel's max-height bounds the popover, and the title
// above stays pinned while this list takes the remaining space and scrolls
// only when the content genuinely exceeds it.
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.player-controls__track {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
color: var(--pc-text);
background: transparent;
border: 0;
border-radius: 10px;
cursor: pointer;
font: inherit;
text-align: left;
}
.player-controls__track span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-controls__track:hover,
.player-controls__track:focus-visible,
.player-controls__track--selected {
background: rgba(255, 255, 255, 0.16);
}
.player-controls__track mat-icon {
color: #ffffff;
font-size: 18px;
width: 18px;
height: 18px;
}
@media (max-width: 720px) {
// Keep the single-row layout when space is tight: the transport and action
// buttons hold their natural width while the middle timeline column
// (the seek slider) absorbs the shrink. Collapsing to a single column here
// is what used to push the transport buttons into a row above the slider.
.player-controls__bar {
gap: 8px;
}
.player-controls__actions {
min-width: 0;
}
.player-controls__volume-popover,
.player-controls__track-popover {
right: 0;
left: auto;
}
}
@@ -0,0 +1,376 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DEFAULT_PLAYER_CAPABILITIES } from './player-controls-defaults';
import { createEmptyControlsState } from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
describe('PlayerControlsComponent', () => {
let fixture: ComponentFixture<PlayerControlsComponent>;
let fake: ReturnType<typeof createFakeController>;
const setCapabilities = (
overrides: Partial<PlayerControlsCapabilities>
) => {
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
...overrides,
});
};
const setState = (overrides: Partial<PlayerControlsState>) => {
fake.state.set({ ...createEmptyControlsState(), ...overrides });
};
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
// Load the real English values so aria-labels resolve to the same text
// the capability/command specs assert against.
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
PLAY: 'Play',
PAUSE: 'Pause',
PREVIOUS_EPISODE: 'Previous episode',
NEXT_EPISODE: 'Next episode',
BACK_10_SECONDS: 'Back 10 seconds',
FORWARD_10_SECONDS: 'Forward 10 seconds',
LIVE_STREAM: 'Live stream',
PLAYBACK_POSITION: 'Playback position',
MUTE: 'Mute',
UNMUTE: 'Unmute',
VOLUME: 'Volume',
VOLUME_LABEL: 'Volume {{percent}}%',
MUTED: 'Muted',
AUDIO_TRACKS: 'Audio tracks',
SUBTITLES: 'Subtitles',
SUBTITLES_OFF: 'Off',
PLAYBACK_SPEED: 'Playback speed',
SPEED_TOOLTIP: 'Speed: {{speed}}',
ASPECT_RATIO: 'Aspect ratio',
ASPECT_DEFAULT: 'Default',
START_RECORDING: 'Start recording',
STOP_RECORDING: 'Stop recording',
ENTER_FULLSCREEN: 'Enter fullscreen',
EXIT_FULLSCREEN: 'Exit fullscreen',
},
},
});
translate.use('en');
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
fixture.componentRef.setInput('controller', fake.controller);
fixture.detectChanges();
});
describe('capability gating', () => {
it('hides every optional control when no capability is enabled', () => {
expect(query('[aria-label="Back 10 seconds"]')).toBeNull();
expect(query('[aria-label="Audio tracks"]')).toBeNull();
expect(query('[aria-label="Subtitles"]')).toBeNull();
expect(query('[aria-label="Playback speed"]')).toBeNull();
expect(query('[aria-label="Aspect ratio"]')).toBeNull();
expect(query('.player-controls__record-button')).toBeNull();
expect(query('[aria-label="Enter fullscreen"]')).toBeNull();
expect(query('[aria-label="Mute"]')).toBeNull();
expect(
query('[data-test-id="player-controls-previous-episode"]')
).toBeNull();
});
it('shows seek controls only when the seek capability is on', () => {
setCapabilities({ seek: true });
fixture.detectChanges();
expect(query('[aria-label="Back 10 seconds"]')).not.toBeNull();
expect(query('[aria-label="Forward 10 seconds"]')).not.toBeNull();
});
it('shows the recording button only when recording is enabled and live', () => {
setCapabilities({ recording: true });
setState({ isLive: true });
fixture.detectChanges();
expect(query('.player-controls__record-button')).not.toBeNull();
setState({ isLive: false });
fixture.detectChanges();
expect(query('.player-controls__record-button')).toBeNull();
});
it('shows the audio menu only with the capability and more than one track', () => {
setCapabilities({ audioTracks: true });
setState({
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
});
fixture.detectChanges();
expect(query('[aria-label="Audio tracks"]')).not.toBeNull();
});
it('shows speed and aspect controls per their capabilities', () => {
setCapabilities({ playbackSpeed: true, aspectRatio: true });
fixture.detectChanges();
expect(query('[aria-label="Playback speed"]')).not.toBeNull();
expect(query('[aria-label="Aspect ratio"]')).not.toBeNull();
});
it('shows the fullscreen button when supported', () => {
setCapabilities({ fullscreen: true });
fixture.detectChanges();
expect(query('[aria-label="Enter fullscreen"]')).not.toBeNull();
});
});
describe('command wiring', () => {
it('toggles play/pause', () => {
query('[aria-label="Play"]')?.click();
expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1);
});
it('presents errors as non-playing and disables playback toggles', () => {
setState({ status: 'error' });
fixture.detectChanges();
const play = query('[aria-label="Play"]') as HTMLButtonElement;
expect(play.disabled).toBe(true);
fixture.componentInstance.togglePlay();
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
it('seeks via the ±10s buttons when seeking is possible', () => {
setCapabilities({ seek: true });
setState({ canSeek: true, durationSeconds: 600 });
fixture.detectChanges();
query('[aria-label="Forward 10 seconds"]')?.click();
query('[aria-label="Back 10 seconds"]')?.click();
expect(fake.commands.seekBy).toHaveBeenCalledWith(10);
expect(fake.commands.seekBy).toHaveBeenCalledWith(-10);
});
it('selects an audio track', () => {
setCapabilities({ audioTracks: true });
setState({
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
});
fixture.detectChanges();
query('[aria-label="Audio tracks"]')?.click();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll(
'.player-controls__track'
) as NodeListOf<HTMLElement>;
items[1].click();
expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2);
});
it('disables the timeline when seeking is not possible', () => {
setCapabilities({ seek: true });
fixture.detectChanges();
const slider = query(
'.player-controls__slider'
) as HTMLInputElement | null;
expect(slider?.disabled).toBe(true);
setState({ canSeek: true, durationSeconds: 600 });
fixture.detectChanges();
const seekable = query(
'.player-controls__slider'
) as HTMLInputElement | null;
expect(seekable?.disabled).toBe(false);
});
it('gives the timeline slider an accessible name', () => {
setCapabilities({ seek: true });
fixture.detectChanges();
expect(query('[aria-label="Playback position"]')).not.toBeNull();
});
it('localizes mute feedback through the active locale', () => {
const translate = TestBed.inject(TranslateService);
translate.setTranslation('de', {
EMBEDDED_MPV: { PLAYER: { MUTED: 'Stumm' } },
});
translate.use('de');
setCapabilities({ volume: true });
fixture.detectChanges();
fixture.componentInstance.toggleMute();
expect(fixture.componentInstance.feedback.current()?.label).toBe(
'Stumm'
);
});
});
describe('auto-hide', () => {
const bar = () => query('.player-controls__bar') as HTMLElement | null;
beforeEach(() => {
jest.useFakeTimers();
// Auto-hide only runs while playing.
setState({ status: 'playing' });
fixture.detectChanges();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('does NOT hide while the bar is hovered, then hides after leave', () => {
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
// Still visible: the pointer rests over the bar.
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
// Now it auto-hides.
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
});
it('reveals on focus, stays visible within the bar, and hides after focus leaves', () => {
setCapabilities({ seek: true });
setState({
status: 'playing',
canSeek: true,
durationSeconds: 600,
});
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
const buttons = Array.from(
bar()?.querySelectorAll('button') ?? []
) as HTMLButtonElement[];
buttons[0].focus();
fixture.detectChanges();
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
jest.advanceTimersByTime(10000);
buttons[1].focus();
fixture.detectChanges();
jest.advanceTimersByTime(10000);
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
const outside = document.createElement('button');
document.body.appendChild(outside);
outside.focus();
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
outside.remove();
});
});
describe('bar-hover state', () => {
const bar = () => query('.player-controls__bar') as HTMLElement | null;
it('tracks barHovered on bar pointerenter / pointerleave', () => {
expect(fixture.componentInstance.barHovered()).toBe(false);
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
expect(fixture.componentInstance.barHovered()).toBe(true);
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
expect(fixture.componentInstance.barHovered()).toBe(false);
});
});
describe('keyboard shortcuts', () => {
const dispatchSpace = () =>
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
})
);
it('toggles play on Space when controls are visible', () => {
dispatchSpace();
expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1);
});
it('does not toggle play on Space when controls are hidden', () => {
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
dispatchSpace();
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
});
describe('episode navigation outputs', () => {
it('emits previous/next episode requests when navigable', () => {
const previous = jest.fn();
const next = jest.fn();
fixture.componentInstance.previousEpisodeRequested.subscribe(
previous
);
fixture.componentInstance.nextEpisodeRequested.subscribe(next);
setCapabilities({ seriesNavigation: true });
setState({ canPreviousEpisode: true, canNextEpisode: true });
fixture.detectChanges();
query('[data-test-id="player-controls-previous-episode"]')?.click();
query('[data-test-id="player-controls-next-episode"]')?.click();
expect(previous).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,400 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
let fixture: ComponentFixture<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
let surface: HTMLElement;
let fullscreenElement: Element | null;
let requestFullscreen: jest.Mock;
let exitFullscreen: jest.Mock;
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const pressKey = (key: string) => {
const event = new KeyboardEvent('keydown', {
key,
cancelable: true,
});
document.dispatchEvent(event);
return event.defaultPrevented;
};
beforeEach(async () => {
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
surface = document.createElement('div');
document.body.appendChild(surface);
fullscreenElement = null;
requestFullscreen = jest.fn(async () => {
fullscreenElement = surface;
document.dispatchEvent(new Event('fullscreenchange'));
});
exitFullscreen = jest.fn(async () => {
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
});
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
(surface as HTMLElement & { requestFullscreen: jest.Mock })[
'requestFullscreen'
] = requestFullscreen;
(document as Document & { exitFullscreen: jest.Mock })[
'exitFullscreen'
] = exitFullscreen;
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('controller', fake.controller);
fixture.componentRef.setInput('playerSurface', surface);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
surface.remove();
jest.useRealTimers();
});
describe('surface interactions', () => {
it('reveals hidden controls on pointer movement over the surface', () => {
jest.useFakeTimers();
setState({ status: 'playing' });
fixture.detectChanges();
jest.advanceTimersByTime(10000);
expect(component.controlsAreVisible()).toBe(false);
surface.dispatchEvent(new MouseEvent('pointermove'));
expect(component.controlsAreVisible()).toBe(true);
});
it('toggles play after a short delay on a plain viewport click', () => {
jest.useFakeTimers();
surface.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
jest.advanceTimersByTime(250);
expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1);
});
it('does not toggle playback from the surface while loading', () => {
jest.useFakeTimers();
setState({ status: 'loading' });
fixture.detectChanges();
surface.dispatchEvent(new MouseEvent('click', { bubbles: true }));
setState({ status: 'playing' });
fixture.detectChanges();
jest.advanceTimersByTime(250);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
it('does not toggle playback if loading starts during the click delay', () => {
jest.useFakeTimers();
setState({ status: 'playing' });
fixture.detectChanges();
surface.dispatchEvent(new MouseEvent('click', { bubbles: true }));
setState({ status: 'loading' });
fixture.detectChanges();
jest.advanceTimersByTime(250);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
it('detaches surface interactions while controls are disabled', () => {
jest.useFakeTimers();
setCapabilities({ fullscreen: true });
const reveal = jest.spyOn(component, 'reveal');
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
surface.dispatchEvent(new MouseEvent('pointermove'));
surface.dispatchEvent(new MouseEvent('click', { bubbles: true }));
surface.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true })
);
jest.advanceTimersByTime(1000);
expect(reveal).not.toHaveBeenCalled();
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
expect(requestFullscreen).not.toHaveBeenCalled();
});
it('a double-click cancels the pending pause and toggles fullscreen', () => {
jest.useFakeTimers();
setCapabilities({ fullscreen: true });
fixture.detectChanges();
surface.dispatchEvent(new MouseEvent('click', { bubbles: true }));
surface.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true })
);
jest.advanceTimersByTime(1000);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
expect(requestFullscreen).toHaveBeenCalledTimes(1);
});
it('ignores clicks and double-clicks on interactive elements', () => {
jest.useFakeTimers();
setCapabilities({ fullscreen: true });
fixture.detectChanges();
const button = document.createElement('button');
surface.appendChild(button);
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
button.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
jest.advanceTimersByTime(1000);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
expect(requestFullscreen).not.toHaveBeenCalled();
});
it('a click while a menu is open dismisses it instead of pausing', () => {
jest.useFakeTimers();
component.toggleMenu('speed');
expect(component.anyMenuOpen()).toBe(true);
surface.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.advanceTimersByTime(1000);
expect(component.anyMenuOpen()).toBe(false);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
it('an outside pointerdown closes open menus', () => {
component.toggleMenu('audio');
expect(component.anyMenuOpen()).toBe(true);
document.body.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
expect(component.anyMenuOpen()).toBe(false);
});
it('keeps menus open for pointerdown inside the sibling controls root', () => {
const controlsRoot = fixture.nativeElement as HTMLElement;
const popoverChild = document.createElement('button');
controlsRoot.appendChild(popoverChild);
document.body.appendChild(controlsRoot);
component.toggleMenu('audio');
popoverChild.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
expect(component.anyMenuOpen()).toBe(true);
document.body.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
expect(component.anyMenuOpen()).toBe(false);
});
it('an outside pointerdown with no open menus is a no-op', () => {
expect(component.anyMenuOpen()).toBe(false);
document.body.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
expect(component.anyMenuOpen()).toBe(false);
});
});
describe('fullscreen', () => {
beforeEach(() => {
setCapabilities({ fullscreen: true });
fixture.detectChanges();
});
it('enters fullscreen on the surface and tracks the change event', async () => {
await component.toggleFullscreen();
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(component.isFullscreen()).toBe(true);
});
it('exits fullscreen when the surface is already fullscreen', async () => {
fullscreenElement = surface;
await component.toggleFullscreen();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(requestFullscreen).not.toHaveBeenCalled();
expect(component.isFullscreen()).toBe(false);
});
it('does nothing when the engine does not support fullscreen', async () => {
setCapabilities({ fullscreen: false });
fixture.detectChanges();
await component.toggleFullscreen();
expect(requestFullscreen).not.toHaveBeenCalled();
});
it('restores the fullscreen cursor when controls are disabled', () => {
jest.useFakeTimers();
surface.style.cursor = 'crosshair';
fullscreenElement = surface;
document.dispatchEvent(new Event('fullscreenchange'));
setState({ status: 'playing' });
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.hideCursor()).toBe(true);
const host = fixture.nativeElement as HTMLElement;
expect(
host.classList.contains('player-controls-host--cursor-hidden')
).toBe(true);
expect(surface.style.cursor).toBe('none');
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
expect(component.hideCursor()).toBe(false);
expect(surface.style.cursor).toBe('crosshair');
});
});
describe('keyboard shortcuts', () => {
it('does not consume or toggle playback while loading', () => {
setState({ status: 'loading' });
fixture.detectChanges();
expect(pressKey(' ')).toBe(false);
expect(pressKey('k')).toBe(false);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
it('does not consume or toggle playback after an error', () => {
setState({ status: 'error' });
fixture.detectChanges();
expect(pressKey(' ')).toBe(false);
expect(pressKey('k')).toBe(false);
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
});
it('Escape closes an open menu', () => {
component.toggleMenu('subtitle');
expect(component.anyMenuOpen()).toBe(true);
pressKey('Escape');
expect(component.anyMenuOpen()).toBe(false);
});
it('f toggles fullscreen', () => {
expect(pressKey('f')).toBe(false);
expect(requestFullscreen).not.toHaveBeenCalled();
setCapabilities({ fullscreen: true });
fixture.detectChanges();
expect(pressKey('f')).toBe(true);
expect(requestFullscreen).toHaveBeenCalledTimes(1);
});
it('arrow left/right seek by ±5 seconds', () => {
setCapabilities({ seek: true });
setState({ canSeek: true, durationSeconds: 600 });
fixture.detectChanges();
expect(pressKey('ArrowRight')).toBe(true);
expect(fake.commands.seekBy).toHaveBeenCalledWith(5);
expect(pressKey('ArrowLeft')).toBe(true);
expect(fake.commands.seekBy).toHaveBeenCalledWith(-5);
});
it('arrow down lowers the volume and flashes feedback', () => {
setCapabilities({ volume: true });
fixture.detectChanges();
expect(pressKey('ArrowDown')).toBe(true);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95);
expect(component.feedback.current()?.label).toBe('95%');
});
it('m mutes the player', () => {
setCapabilities({ volume: true });
fixture.detectChanges();
expect(pressKey('m')).toBe(true);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(component.displayVolume()).toBe(0);
});
it('ignores playback shortcuts while shortcuts are disabled', () => {
setCapabilities({ volume: true });
fixture.componentRef.setInput('shortcutsEnabled', false);
fixture.detectChanges();
pressKey(' ');
pressKey('m');
expect(fake.commands.togglePlay).not.toHaveBeenCalled();
expect(fake.commands.setVolume).not.toHaveBeenCalled();
});
});
describe('episode navigation', () => {
it('emits only when a previous/next episode exists', () => {
const previous = jest.fn();
const next = jest.fn();
component.previousEpisodeRequested.subscribe(previous);
component.nextEpisodeRequested.subscribe(next);
component.requestPreviousEpisode();
component.requestNextEpisode();
expect(previous).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
setState({ canPreviousEpisode: true, canNextEpisode: true });
fixture.detectChanges();
component.requestPreviousEpisode();
component.requestNextEpisode();
expect(previous).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,208 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
function createFakeController() {
const capabilities = signal({ ...DEFAULT_PLAYER_CAPABILITIES });
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
};
const controller: PlayerController = {
capabilities,
state,
commands,
};
return { controller, capabilities, state, commands };
}
describe('PlayerControlsComponent timeline scrubbing', () => {
let fixture: ComponentFixture<PlayerControlsComponent>;
let fake: ReturnType<typeof createFakeController>;
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const slider = () =>
fixture.nativeElement.querySelector(
'.player-controls__slider'
) as HTMLInputElement;
const currentTimeText = () =>
(
fixture.nativeElement.querySelector(
'.player-controls__time > span:first-child'
) as HTMLElement
).textContent;
const dispatch = (
type: 'input' | 'change',
value: string,
rawValue = false
) => {
const element = slider();
if (rawValue) {
Object.defineProperty(element, 'value', {
configurable: true,
value,
writable: true,
});
} else {
element.value = value;
}
element.dispatchEvent(new Event(type, { bubbles: true }));
fixture.detectChanges();
return element;
};
beforeEach(async () => {
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
fixture.componentRef.setInput('controller', fake.controller);
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
seek: true,
});
setState({
canSeek: true,
durationSeconds: 600,
positionSeconds: 30,
});
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
});
it('previews timeline input locally without seeking', () => {
const element = dispatch('input', '120');
expect(fake.commands.seekTo).not.toHaveBeenCalled();
expect(element.value).toBe('120');
expect(element.style.getPropertyValue('--slider-progress')).toBe('20%');
expect(element.getAttribute('aria-valuetext')).toBe('2:00');
expect(currentTimeText()).toBe('2:00');
});
it('keeps the local preview while controller position updates', () => {
dispatch('input', '60');
setState({
canSeek: true,
durationSeconds: 600,
positionSeconds: 45,
});
fixture.detectChanges();
expect(fake.commands.seekTo).not.toHaveBeenCalled();
expect(slider().value).toBe('60');
expect(slider().getAttribute('aria-valuetext')).toBe('1:00');
expect(currentTimeText()).toBe('1:00');
});
it('clears the local preview when controller state becomes non-seekable', () => {
dispatch('input', '120');
setState({
canSeek: false,
isLive: true,
positionSeconds: 5,
});
fixture.detectChanges();
expect(fixture.componentInstance.scrubPosition()).toBeNull();
expect(currentTimeText()).toBe('0:05');
});
it('does not resurrect a preview after seek capability is restored', () => {
dispatch('input', '120');
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
fixture.detectChanges();
expect(fixture.componentInstance.scrubPosition()).toBeNull();
expect(slider()).toBeNull();
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
seek: true,
});
setState({
canSeek: true,
durationSeconds: 300,
positionSeconds: 15,
});
fixture.detectChanges();
expect(slider().value).toBe('15');
expect(currentTimeText()).toBe('0:15');
});
it('commits exactly one seek on change and clears the preview', () => {
dispatch('input', '55');
dispatch('input', '60');
expect(fake.commands.seekTo).not.toHaveBeenCalled();
expect(currentTimeText()).toBe('1:00');
const element = dispatch('change', '60');
expect(fake.commands.seekTo).toHaveBeenCalledTimes(1);
expect(fake.commands.seekTo).toHaveBeenCalledWith(60);
expect(element.value).toBe('30');
expect(element.style.getPropertyValue('--slider-progress')).toBe('5%');
expect(element.getAttribute('aria-valuetext')).toBe('0:30');
expect(currentTimeText()).toBe('0:30');
});
it.each([
['below', '-10', 0, '0:00'],
['above', '999', 600, '10:00'],
])(
'clamps %s-range scrub values before commit',
(_boundary, raw, expected, expectedTime) => {
const element = dispatch('input', raw, true);
expect(fake.commands.seekTo).not.toHaveBeenCalled();
expect(element.getAttribute('aria-valuetext')).toBe(expectedTime);
expect(currentTimeText()).toBe(expectedTime);
dispatch('change', raw, true);
expect(fake.commands.seekTo).toHaveBeenCalledTimes(1);
expect(fake.commands.seekTo).toHaveBeenCalledWith(expected);
}
);
it('ignores invalid scrub values without seeking', () => {
dispatch('input', 'not-a-number', true);
dispatch('change', 'not-a-number', true);
expect(fake.commands.seekTo).not.toHaveBeenCalled();
expect(currentTimeText()).toBe('0:30');
});
});
@@ -0,0 +1,399 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
computed,
effect,
inject,
input,
output,
signal,
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ControlsFeedback } from './controls-feedback';
import { ControlsFullscreen } from './controls-fullscreen';
import { ControlsMenuSelection } from './controls-menu-selection';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsShortcuts } from './controls-shortcuts';
import { ControlsSurface } from './controls-surface';
import { ControlsVisibility } from './controls-visibility';
import { createControlsViewModel } from './controls-view-model';
import { ControlsVolume } from './controls-volume';
import { formatTime, speedLabel } from './controls-format.utils';
import type { PlayerController } from './player-controls.model';
@Component({
selector: 'app-player-controls',
templateUrl: './player-controls.component.html',
styleUrl: './player-controls.component.scss',
imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-controls-host',
'[class.player-controls-host--cursor-hidden]': 'hideCursor()',
},
})
export class PlayerControlsComponent implements OnDestroy {
private readonly host = inject(ElementRef<HTMLElement>).nativeElement;
private readonly translate = inject(TranslateService);
readonly controller = input.required<PlayerController>();
readonly playerSurface = input<HTMLElement | null>(null);
readonly showControls = input(true);
readonly shortcutsEnabled = input(true);
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly barHovered = signal(false);
private readonly barFocused = signal(false);
readonly menus = new ControlsMenuState();
readonly feedback = new ControlsFeedback();
readonly anyMenuOpen = this.menus.anyOpen;
private readonly shortcuts = new ControlsShortcuts();
private readonly visibility = new ControlsVisibility(() => this.canHide());
private readonly fullscreen = new ControlsFullscreen(
() => this.playerSurface(),
() => this.reveal()
);
private readonly volume = new ControlsVolume({
apply: (value) => this.controller().commands.setVolume(value),
flash: (icon, label) => this.feedback.flash(icon, label),
mutedLabel: () => this.translate.instant('EMBEDDED_MPV.PLAYER.MUTED'),
openPopover: () => this.menus.open('volume'),
closePopover: () => this.menus.close('volume'),
});
private readonly surface = new ControlsSurface(
{
reveal: () => this.reveal(),
toggleFullscreen: () => void this.toggleFullscreen(),
closePopovers: () => this.closePopovers(),
togglePlay: () => this.togglePlay(),
canTogglePlay: () => this.canTogglePlay(),
isMenuOpen: () => this.menus.anyOpen(),
},
this.host
);
readonly menuSelection = new ControlsMenuSelection({
commands: () => this.controller().commands,
menus: this.menus,
visibility: this.visibility,
revealSticky: () => this.reveal({ scheduleHide: false }),
});
readonly state = computed(() => this.controller().state());
readonly capabilities = computed(() => this.controller().capabilities());
private readonly controllerVolume = computed(() => this.state().volume);
readonly scrubPosition = signal<number | null>(null);
readonly timelineDuration = computed(() => {
const duration = this.state().durationSeconds;
return typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: 0;
});
readonly timelineValue = computed(
() =>
this.normalizeTimelineValue(
this.scrubPosition() ?? this.state().positionSeconds
) ?? 0
);
readonly timelineProgress = computed(() => {
const duration = this.timelineDuration();
return this.state().canSeek && duration > 0
? (this.timelineValue() / duration) * 100
: 0;
});
readonly displayVolume = this.volume.value;
readonly isFullscreen = this.fullscreen.isFullscreen;
private readonly vm = createControlsViewModel({
state: this.state,
capabilities: this.capabilities,
volume: this.volume.value,
isFullscreen: this.isFullscreen,
canFullscreenNative: () => this.fullscreen.canFullscreen(),
showControls: this.showControls,
autoHideVisible: this.visibility.visible,
anyMenuOpen: this.menus.anyOpen,
});
readonly isLoading = this.vm.isLoading;
readonly isPaused = this.vm.isPaused;
readonly isPlaying = this.vm.isPlaying;
readonly canTogglePlay = this.vm.canTogglePlay;
readonly hasAudioTracks = this.vm.hasAudioTracks;
readonly hasSubtitleTracks = this.vm.hasSubtitleTracks;
readonly canRecord = this.vm.canRecord;
readonly isRecording = this.vm.isRecording;
readonly recordingStatusText = this.vm.recordingStatusText;
readonly volumeIcon = this.vm.volumeIcon;
readonly canFullscreen = this.vm.canFullscreen;
readonly volumePercent = computed(() =>
Math.round(this.displayVolume() * 100)
);
readonly controlsAreVisible = this.vm.controlsAreVisible;
readonly hideCursor = this.vm.hideCursor;
constructor() {
this.shortcuts.attach({
isAvailable: () => this.shortcutsEnabled() && this.showControls(),
canTogglePaused: () => this.canTogglePlay(),
canSeek: () => this.capabilities().seek && this.state().canSeek,
canAdjustVolume: () => this.capabilities().volume,
canToggleFullscreen: () => this.canFullscreen(),
onEscape: () => this.closePopovers(),
togglePaused: () => this.togglePlay(),
toggleFullscreen: () => void this.toggleFullscreen(),
seekBy: (delta) => this.seekBy(delta),
adjustVolume: (delta) => this.adjustVolume(delta),
toggleMute: () => this.toggleMute(),
});
effect((onCleanup) => {
const surface = this.showControls() ? this.playerSurface() : null;
onCleanup(this.surface.attachSurface(surface));
});
effect(() => {
const controller = this.controller();
if (
!this.volume.beginCapabilityEpoch(
controller,
this.capabilities().volume
)
) {
return;
}
const volume = this.controllerVolume();
untracked(() =>
this.volume.initializeController(controller, volume)
);
});
effect(() => {
const controller = this.controller();
const volume = this.controllerVolume();
untracked(() =>
this.volume.reconcileController(controller, volume)
);
});
effect((onCleanup) => {
const surface = this.playerSurface();
if (!surface || !this.hideCursor()) {
return;
}
const previousCursor = surface.style.cursor;
surface.style.cursor = 'none';
onCleanup(() => {
if (surface.style.cursor === 'none') {
surface.style.cursor = previousCursor;
}
});
});
effect(() => {
const state = this.state();
const showControls = this.showControls();
const capabilities = this.capabilities();
untracked(() => {
if (!capabilities.seek || !state.canSeek) {
this.scrubPosition.set(null);
}
this.menus.reconcileControllerAvailability(
showControls,
capabilities,
state
);
this.visibility.scheduleHide();
this.feedback.flashRecordingTransition(state.recording.active, {
active: this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING'
),
inactive:
state.recording.message ||
this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECORDING_SAVED'
),
});
});
});
}
ngOnDestroy(): void {
this.shortcuts.detach();
this.feedback.dispose();
this.visibility.dispose();
this.fullscreen.dispose();
this.volume.dispose();
this.surface.dispose();
}
formatTime = formatTime;
speedLabel = speedLabel;
togglePlay(): void {
this.reveal();
if (!this.canTogglePlay()) {
return;
}
this.controller().commands.togglePlay();
}
seekBy(deltaSeconds: number): void {
this.reveal();
if (!this.capabilities().seek || !this.state().canSeek) {
return;
}
this.controller().commands.seekBy(deltaSeconds);
this.feedback.flash(
deltaSeconds >= 0 ? 'forward_10' : 'replay_10',
`${deltaSeconds >= 0 ? '+' : ''}${Math.round(deltaSeconds)}s`
);
}
onTimelineInput(event: Event): void {
this.reveal();
this.scrubPosition.set(this.readTimelineValue(event));
}
onTimelineCommit(event: Event): void {
this.reveal();
const target = this.readTimelineValue(event);
this.scrubPosition.set(null);
if (
target === null ||
!this.capabilities().seek ||
!this.state().canSeek
) {
return;
}
this.controller().commands.seekTo(target);
}
requestPreviousEpisode(): void {
this.reveal();
if (!this.state().canPreviousEpisode) {
return;
}
this.previousEpisodeRequested.emit();
}
requestNextEpisode(): void {
this.reveal();
if (!this.state().canNextEpisode) {
return;
}
this.nextEpisodeRequested.emit();
}
onVolumeInput(event: Event): void {
this.volume.set(Number((event.target as HTMLInputElement).value));
this.reveal({ scheduleHide: false });
}
onVolumeWheel(event: WheelEvent): void {
event.preventDefault();
this.adjustVolume(event.deltaY > 0 ? -0.05 : 0.05);
}
onVolumeHoverEnter(): void {
this.volume.hoverEnter();
}
onVolumeHoverLeave(): void {
this.volume.hoverLeave();
}
toggleMute(): void {
if (!this.capabilities().volume) {
return;
}
this.volume.toggleMute();
this.reveal();
}
toggleMenu(menu: 'audio' | 'subtitle' | 'speed' | 'aspect'): void {
this.menus.toggle(menu);
this.reveal();
}
toggleRecording(): void {
if (!this.canRecord()) {
return;
}
this.reveal({ scheduleHide: false });
this.controller().commands.toggleRecording();
}
async toggleFullscreen(): Promise<void> {
this.reveal();
if (!this.canFullscreen()) {
return;
}
await this.fullscreen.toggle();
}
private adjustVolume(delta: number): void {
if (!this.capabilities().volume) {
return;
}
this.volume.adjust(delta);
this.reveal();
}
private readTimelineValue(event: Event): number | null {
return this.normalizeTimelineValue(
Number((event.target as HTMLInputElement).value)
);
}
private normalizeTimelineValue(value: number): number | null {
if (!Number.isFinite(value)) {
return null;
}
const duration = this.state().durationSeconds;
const upperBound =
typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: Number.POSITIVE_INFINITY;
return Math.min(Math.max(0, value), upperBound);
}
private closePopovers(): void {
if (!this.menus.anyOpen()) {
return;
}
this.menus.closeAll();
this.visibility.scheduleHide();
}
reveal(options: { scheduleHide?: boolean } = {}): void {
this.shortcuts.activate();
this.visibility.reveal(options);
}
onBarPointerEnter(): void {
this.barHovered.set(true);
this.reveal({ scheduleHide: false });
}
onBarPointerLeave(): void {
this.barHovered.set(false);
this.visibility.scheduleHide();
}
onBarFocusIn(): void {
this.barFocused.set(true);
this.reveal({ scheduleHide: false });
}
onBarFocusOut(event: FocusEvent): void {
const bar = event.currentTarget as HTMLElement | null;
const next = event.relatedTarget;
if (bar && next instanceof Node && bar.contains(next)) {
return;
}
this.barFocused.set(false);
this.visibility.scheduleHide();
}
private canHide(): boolean {
return (
this.isPlaying() &&
!this.barHovered() &&
!this.barFocused() &&
!this.menus.anyOpen() &&
!this.state().statusMessage
);
}
}
@@ -0,0 +1,89 @@
import type { Signal } from '@angular/core';
export type PlayerStatus =
| 'idle'
| 'loading'
| 'playing'
| 'paused'
| 'ended'
| 'error';
/** Which controls an engine supports. A control is only rendered when its flag is true. */
export interface PlayerControlsCapabilities {
seek: boolean; // scrub bar / ±10s (VOD)
volume: boolean;
audioTracks: boolean;
subtitles: boolean;
playbackSpeed: boolean;
aspectRatio: boolean;
recording: boolean;
fullscreen: boolean;
seriesNavigation: boolean;
}
export interface PlayerTrack {
id: number;
label: string; // adapter pre-computes the display label
selected: boolean;
}
export interface PlayerPreset<T> {
value: T;
label: string;
}
export interface PlayerRecordingState {
active: boolean;
/** Seconds since recording started; adapter computes via a 1s tick. */
elapsedSeconds: number;
/** Persistent status text (e.g. "Saved to …" / error). null when none. */
message: string | null;
}
export interface PlayerControlsState {
status: PlayerStatus;
/** Loading/error text for the player surface; '' when nothing to show. */
statusMessage: string;
stalled: boolean;
positionSeconds: number;
durationSeconds: number | null;
isLive: boolean;
canSeek: boolean;
volume: number; // 0..1
audioTracks: PlayerTrack[];
subtitleTracks: PlayerTrack[];
/** True when a subtitle track is active (selected id !== null/off). */
subtitlesEnabled: boolean;
playbackSpeed: number;
speedPresets: ReadonlyArray<PlayerPreset<number>>;
aspectRatio: string;
aspectPresets: ReadonlyArray<PlayerPreset<string>>;
recording: PlayerRecordingState;
canPreviousEpisode: boolean;
canNextEpisode: boolean;
}
/** Imperative command surface. All fire-and-forget (void). */
export interface PlayerControlsCommands {
togglePlay(): void;
seekTo(seconds: number): void;
seekBy(deltaSeconds: number): void;
setVolume(value: number): void; // 0..1
setAudioTrack(id: number): void;
setSubtitleTrack(id: number): void; // -1 = off
setPlaybackSpeed(speed: number): void;
setAspectRatio(value: string): void;
toggleRecording(): void;
}
/**
* What any engine implements to drive the shared controls. Intentionally has NO
* component-lifecycle assumptions — a controller may live above the router
* (background-playback readiness, subissue 04). Episode navigation and
* fullscreen are NOT here: they are controls-component outputs / DOM affordances.
*/
export interface PlayerController {
readonly capabilities: Signal<PlayerControlsCapabilities>;
readonly state: Signal<PlayerControlsState>;
readonly commands: PlayerControlsCommands;
}
@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import {
WEB_PLAYER_SHARED_CONTROLS,
WEB_PLAYER_SHARED_CONTROLS_ENABLED,
} from './web-player-controls.flag';
describe('WEB_PLAYER_SHARED_CONTROLS flag', () => {
afterEach(() => TestBed.resetTestingModule());
it('is rolled out OFF by default', () => {
expect(WEB_PLAYER_SHARED_CONTROLS_ENABLED).toBe(false);
});
it('resolves the constant through the root injection token', () => {
TestBed.configureTestingModule({});
expect(TestBed.inject(WEB_PLAYER_SHARED_CONTROLS)).toBe(
WEB_PLAYER_SHARED_CONTROLS_ENABLED
);
});
it('can be overridden via a TestBed provider', () => {
TestBed.configureTestingModule({
providers: [
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true },
],
});
expect(TestBed.inject(WEB_PLAYER_SHARED_CONTROLS)).toBe(true);
});
});
@@ -0,0 +1,24 @@
import { InjectionToken } from '@angular/core';
/**
* Reserved rollout switch for shared `app-player-controls` chrome on the web
* video engines (Video.js, html5+hls.js, ArtPlayer).
*
* DEFAULT OFF. #1148 adds no runtime consumer, so changing this constant alone
* has no effect until a follow-up engine host reads the injectable
* {@link WEB_PLAYER_SHARED_CONTROLS} token and performs the actual UI switch.
*/
export const WEB_PLAYER_SHARED_CONTROLS_ENABLED = false;
/**
* Injectable view of {@link WEB_PLAYER_SHARED_CONTROLS_ENABLED}. Components
* inject this token; specs override it via TestBed providers without mocking a
* module-level constant.
*/
export const WEB_PLAYER_SHARED_CONTROLS = new InjectionToken<boolean>(
'WEB_PLAYER_SHARED_CONTROLS',
{
providedIn: 'root',
factory: () => WEB_PLAYER_SHARED_CONTROLS_ENABLED,
}
);
@@ -0,0 +1,319 @@
import { signal } from '@angular/core';
import { WebVideoControlsAdapter } from './web-video-controls.adapter';
import {
createEmptyControlsState,
DEFAULT_PLAYER_CAPABILITIES,
} from './player-controls-defaults';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
/** jsdom `<video>` with overridable readonly media props (command-path spec). */
function createVideo(
overrides: Partial<{
duration: number;
readyState: number;
networkState: number;
paused: boolean;
ended: boolean;
error: MediaError | null;
seekableLength: number;
muted: boolean;
volume: number;
currentTime: number;
playbackRate: number;
playRejects: boolean;
}> = {}
): HTMLVideoElement {
const video = document.createElement('video');
const define = (prop: string, value: unknown) =>
Object.defineProperty(video, prop, {
configurable: true,
writable: true,
value,
});
define('duration', overrides.duration ?? NaN);
define('readyState', overrides.readyState ?? 4);
define('networkState', overrides.networkState ?? 1);
define('paused', overrides.paused ?? false);
define('ended', overrides.ended ?? false);
define('error', overrides.error ?? null);
define('seekable', { length: overrides.seekableLength ?? 0 });
define('muted', overrides.muted ?? false);
define('volume', overrides.volume ?? 1);
define('currentTime', overrides.currentTime ?? 0);
define('playbackRate', overrides.playbackRate ?? 1);
define(
'play',
overrides.playRejects
? jest.fn().mockRejectedValue(new Error('blocked'))
: jest.fn().mockResolvedValue(undefined)
);
define('pause', jest.fn());
return video;
}
describe('WebVideoControlsAdapter (commands & edge branches)', () => {
let adapter: WebVideoControlsAdapter;
beforeEach(() => {
adapter = new WebVideoControlsAdapter();
});
afterEach(() => adapter.detach());
it('pauses a playing video and resumes an ended one', () => {
const playing = createVideo({ paused: false });
adapter.attach(playing);
adapter.commands.togglePlay();
expect(playing.pause).toHaveBeenCalled();
const ended = createVideo({ paused: false, ended: true });
const endedAdapter = new WebVideoControlsAdapter();
endedAdapter.attach(ended);
endedAdapter.commands.togglePlay();
expect(ended.play).toHaveBeenCalled();
endedAdapter.detach();
});
it('swallows play() rejections (autoplay policies)', async () => {
const video = createVideo({ paused: true, playRejects: true });
adapter.attach(video);
expect(() => adapter.commands.togglePlay()).not.toThrow();
await Promise.resolve();
expect(video.play).toHaveBeenCalled();
});
it('ignores every command while no video is attached', () => {
expect(() => {
adapter.commands.togglePlay();
adapter.commands.seekTo(10);
adapter.commands.seekBy(5);
adapter.commands.setVolume(0.5);
adapter.commands.setPlaybackSpeed(2);
adapter.commands.setAudioTrack(1);
adapter.commands.setSubtitleTrack(-1);
adapter.commands.setAspectRatio('16:9');
adapter.commands.toggleRecording();
}).not.toThrow();
});
it('reports no capabilities before a video is attached', () => {
expect(adapter.capabilities()).toEqual(DEFAULT_PLAYER_CAPABILITIES);
});
it('disables seek capability for an infinite-duration live stream', () => {
adapter.attach(createVideo({ duration: Infinity }));
expect(adapter.state().isLive).toBe(true);
expect(adapter.capabilities().seek).toBe(false);
});
it('disables seek capability for an explicitly classified live stream', () => {
adapter.attach(createVideo({ duration: 120, seekableLength: 1 }), {
isLive: () => true,
});
expect(adapter.state().isLive).toBe(true);
expect(adapter.capabilities().seek).toBe(false);
});
it('keeps seek capability for VOD while seekable ranges warm up', () => {
adapter.attach(createVideo({ duration: 120, seekableLength: 0 }), {
isLive: () => false,
});
expect(adapter.state().isLive).toBe(false);
expect(adapter.state().canSeek).toBe(false);
expect(adapter.capabilities().seek).toBe(true);
});
it('ignores track selection when the engine exposes no accessors', () => {
adapter.attach(createVideo());
expect(() => {
adapter.commands.setAudioTrack(1);
adapter.commands.setSubtitleTrack(0);
}).not.toThrow();
});
it('rejects non-finite or non-positive playback speeds', () => {
const video = createVideo({ playbackRate: 1 });
adapter.attach(video);
adapter.commands.setPlaybackSpeed(NaN);
adapter.commands.setPlaybackSpeed(Infinity);
adapter.commands.setPlaybackSpeed(0);
adapter.commands.setPlaybackSpeed(-1);
expect(video.playbackRate).toBe(1);
adapter.commands.setPlaybackSpeed(1.25);
expect(video.playbackRate).toBe(1.25);
});
it('rejects non-finite seek and volume values', () => {
const video = createVideo({
duration: 100,
currentTime: 20,
volume: 0.5,
});
adapter.attach(video);
adapter.commands.seekTo(NaN);
adapter.commands.seekTo(Infinity);
adapter.commands.seekBy(NaN);
adapter.commands.setVolume(NaN);
adapter.commands.setVolume(Infinity);
expect(video.currentTime).toBe(20);
expect(video.volume).toBe(0.5);
});
it('swallows native media setter exceptions', () => {
const seekVideo = createVideo({ duration: 100, currentTime: 20 });
Object.defineProperty(seekVideo, 'currentTime', {
configurable: true,
get: () => 20,
set: () => {
throw new Error('seek blocked');
},
});
adapter.attach(seekVideo);
expect(() => adapter.commands.seekTo(30)).not.toThrow();
const volumeVideo = createVideo({ volume: 0.5 });
Object.defineProperty(volumeVideo, 'volume', {
configurable: true,
get: () => 0.5,
set: () => {
throw new Error('volume blocked');
},
});
const volumeAdapter = new WebVideoControlsAdapter();
volumeAdapter.attach(volumeVideo);
expect(() => volumeAdapter.commands.setVolume(0.25)).not.toThrow();
volumeAdapter.detach();
const speedVideo = createVideo({ playbackRate: 1 });
Object.defineProperty(speedVideo, 'playbackRate', {
configurable: true,
get: () => 1,
set: () => {
throw new Error('speed blocked');
},
});
const speedAdapter = new WebVideoControlsAdapter();
speedAdapter.attach(speedVideo);
expect(() => speedAdapter.commands.setPlaybackSpeed(1.5)).not.toThrow();
speedAdapter.detach();
});
it('does not clamp seekTo when the duration is not finite', () => {
const video = createVideo({ duration: Infinity });
adapter.attach(video);
adapter.commands.seekTo(1234);
expect(video.currentTime).toBe(1234);
});
it('clamps setVolume into [0, 1] and mutes only at zero', () => {
const video = createVideo();
adapter.attach(video);
adapter.commands.setVolume(1.5);
expect(video.volume).toBe(1);
expect(video.muted).toBe(false);
adapter.commands.setVolume(-0.5);
expect(video.volume).toBe(0);
expect(video.muted).toBe(true);
});
it('detach is idempotent and re-attach rebinds listeners once', () => {
const first = createVideo();
const removeSpy = jest.spyOn(first, 'removeEventListener');
adapter.attach(first);
adapter.detach();
adapter.detach();
const callsAfterDoubleDetach = removeSpy.mock.calls.length;
adapter.detach();
expect(removeSpy.mock.calls.length).toBe(callsAfterDoubleDetach);
// Attaching a new element detaches the previous one.
const second = createVideo();
adapter.attach(first);
adapter.attach(second);
expect(
removeSpy.mock.calls.filter(([event]) => event === 'play').length
).toBeGreaterThanOrEqual(2);
});
it('restores the canonical empty state and preserves series context', () => {
const seriesNavigation = signal<SeriesPlaybackNavigation | null>({
canPrevious: true,
canNext: false,
autoplayEnabled: true,
});
adapter.setContext({ seriesNavigation });
expect(adapter.state()).toEqual(createEmptyControlsState());
adapter.attach(createVideo({ duration: 120, volume: 0.4 }), {
isLive: () => false,
});
expect(adapter.state().status).toBe('playing');
expect(adapter.capabilities().seek).toBe(true);
expect(adapter.capabilities().seriesNavigation).toBe(true);
adapter.detach();
expect(adapter.state()).toEqual(createEmptyControlsState());
expect(adapter.capabilities()).toEqual(DEFAULT_PLAYER_CAPABILITIES);
adapter.attach(createVideo({ duration: 120 }), {
isLive: () => false,
});
expect(adapter.capabilities().seriesNavigation).toBe(true);
});
it('clears engine callbacks on detach', () => {
const setAudioTrack = jest.fn();
adapter.attach(createVideo(), { setAudioTrack });
adapter.detach();
adapter.commands.setAudioTrack(1);
expect(setAudioTrack).not.toHaveBeenCalled();
});
it('treats a throwing seekable getter as not seekable', () => {
const video = createVideo({ duration: 100 });
Object.defineProperty(video, 'seekable', {
configurable: true,
get() {
throw new Error('not supported');
},
});
adapter.attach(video, { isLive: () => false });
expect(adapter.state().canSeek).toBe(false);
});
it('keeps paused warmup playable but maps playing warmup to loading', () => {
const pausedWarmup = createVideo({ paused: true, readyState: 1 });
adapter.attach(pausedWarmup);
expect(adapter.state().status).toBe('paused');
const playingWarmup = createVideo({ paused: false, readyState: 2 });
const other = new WebVideoControlsAdapter();
other.attach(playingWarmup);
expect(other.state().status).toBe('loading');
other.detach();
});
it('falls back to finite element duration for liveness when isLive is omitted', () => {
adapter.attach(createVideo({ duration: 42, seekableLength: 1 }));
expect(adapter.state().isLive).toBe(false);
expect(adapter.state().durationSeconds).toBe(42);
});
it('normalizes a zero duration to null', () => {
adapter.attach(createVideo({ duration: 0 }), { isLive: () => false });
expect(adapter.state().durationSeconds).toBeNull();
expect(adapter.state().canSeek).toBe(false);
});
});
@@ -0,0 +1,154 @@
import type { PlayerTrack } from './player-controls.model';
import { WebVideoControlsAdapter } from './web-video-controls.adapter';
interface VideoOverrides {
duration: number;
readyState: number;
networkState: number;
paused: boolean;
ended: boolean;
error: MediaError | null;
seekableLength: number;
}
function createVideo(
overrides: Partial<VideoOverrides> = {}
): HTMLVideoElement {
const video = document.createElement('video');
const define = (prop: string, value: unknown) =>
Object.defineProperty(video, prop, {
configurable: true,
writable: true,
value,
});
define('duration', overrides.duration ?? NaN);
define('readyState', overrides.readyState ?? 4);
define('networkState', overrides.networkState ?? 1);
define('paused', overrides.paused ?? false);
define('ended', overrides.ended ?? false);
define('error', overrides.error ?? null);
define('seekable', { length: overrides.seekableLength ?? 0 });
return video;
}
describe('WebVideoControlsAdapter contract', () => {
let adapter: WebVideoControlsAdapter;
beforeEach(() => {
adapter = new WebVideoControlsAdapter();
});
afterEach(() => adapter.detach());
it('requires both selectable audio tracks and an audio setter', () => {
const single: PlayerTrack[] = [
{ id: 0, label: 'English', selected: true },
];
const multiple: PlayerTrack[] = [
{ id: 0, label: 'English', selected: true },
{ id: 1, label: 'German', selected: false },
];
adapter.attach(createVideo(), {
getAudioTracks: () => multiple,
});
expect(adapter.capabilities().audioTracks).toBe(false);
adapter.attach(createVideo(), {
getAudioTracks: () => single,
setAudioTrack: jest.fn(),
});
expect(adapter.capabilities().audioTracks).toBe(false);
adapter.attach(createVideo(), {
getAudioTracks: () => multiple,
setAudioTrack: jest.fn(),
});
expect(adapter.capabilities().audioTracks).toBe(true);
});
it('requires both subtitle tracks and a subtitle setter', () => {
const subtitles: PlayerTrack[] = [
{ id: 0, label: 'English', selected: true },
];
adapter.attach(createVideo(), {
getSubtitleTracks: () => subtitles,
});
expect(adapter.capabilities().subtitles).toBe(false);
adapter.attach(createVideo(), {
getSubtitleTracks: () => [],
setSubtitleTrack: jest.fn(),
});
expect(adapter.capabilities().subtitles).toBe(false);
adapter.attach(createVideo(), {
getSubtitleTracks: () => subtitles,
setSubtitleTrack: jest.fn(),
});
expect(adapter.capabilities().subtitles).toBe(true);
});
it.each([NaN, Number.NEGATIVE_INFINITY])(
'does not classify an unknown duration (%s) as live',
(duration) => {
adapter.attach(createVideo({ duration }));
expect(adapter.state().isLive).toBe(false);
expect(adapter.state().durationSeconds).toBeNull();
expect(adapter.capabilities().seek).toBe(true);
}
);
it('treats getDuration positive Infinity as authoritative', () => {
adapter.attach(
createVideo({
duration: 90,
seekableLength: 1,
}),
{ getDuration: () => Number.POSITIVE_INFINITY }
);
expect(adapter.state().isLive).toBe(true);
expect(adapter.state().durationSeconds).toBeNull();
expect(adapter.state().canSeek).toBe(false);
});
it('maps an attached element with an empty network to idle', () => {
adapter.attach(
createVideo({
networkState: 0,
readyState: 0,
paused: true,
})
);
expect(adapter.state().status).toBe('idle');
});
it('keeps a paused preload or warmup user-playable', () => {
adapter.attach(
createVideo({
networkState: 1,
readyState: 0,
paused: true,
})
);
expect(adapter.state().status).toBe('paused');
});
it('maps actively playing media with insufficient data to loading', () => {
adapter.attach(
createVideo({
networkState: 2,
readyState: 2,
paused: false,
})
);
expect(adapter.state().status).toBe('loading');
});
});
@@ -0,0 +1,139 @@
import { WebVideoControlsAdapter } from './web-video-controls.adapter';
interface VideoOverrides {
duration: number;
readyState: number;
networkState: number;
paused: boolean;
currentTime: number;
seekableLength: number;
}
function createVideo(
overrides: Partial<VideoOverrides> = {}
): HTMLVideoElement {
const video = document.createElement('video');
Object.defineProperties(video, {
duration: {
configurable: true,
writable: true,
value: overrides.duration ?? 120,
},
paused: {
configurable: true,
writable: true,
value: overrides.paused ?? true,
},
readyState: {
configurable: true,
writable: true,
value: overrides.readyState ?? 1,
},
networkState: {
configurable: true,
writable: true,
value: overrides.networkState ?? 1,
},
currentTime: {
configurable: true,
writable: true,
value: overrides.currentTime ?? 0,
},
seekable: {
configurable: true,
writable: true,
value: { length: overrides.seekableLength ?? 0 },
},
});
return video;
}
function setMediaProp(
video: HTMLVideoElement,
property: string,
value: unknown
): void {
Object.defineProperty(video, property, {
configurable: true,
writable: true,
value,
});
}
describe('WebVideoControlsAdapter media events', () => {
let adapter: WebVideoControlsAdapter;
beforeEach(() => {
adapter = new WebVideoControlsAdapter();
});
afterEach(() => adapter.detach());
it.each(['loadedmetadata', 'loadeddata', 'canplay'] as const)(
'retains readiness invalidation on %s',
(eventName) => {
const video = createVideo({ duration: NaN });
adapter.attach(video, { isLive: () => false });
expect(adapter.state().durationSeconds).toBeNull();
setMediaProp(video, 'duration', 120);
video.dispatchEvent(new Event(eventName));
expect(adapter.state().durationSeconds).toBe(120);
}
);
it('clears cached resource state when emptied fires', () => {
const video = createVideo({
duration: 120,
readyState: 4,
networkState: 1,
currentTime: 30,
seekableLength: 1,
});
adapter.attach(video, { isLive: () => false });
expect(adapter.state().durationSeconds).toBe(120);
expect(adapter.state().canSeek).toBe(true);
setMediaProp(video, 'duration', NaN);
setMediaProp(video, 'readyState', 0);
setMediaProp(video, 'networkState', 0);
setMediaProp(video, 'currentTime', 0);
setMediaProp(video, 'seekable', { length: 0 });
video.dispatchEvent(new Event('emptied'));
expect(adapter.state().status).toBe('idle');
expect(adapter.state().durationSeconds).toBeNull();
expect(adapter.state().positionSeconds).toBe(0);
expect(adapter.state().canSeek).toBe(false);
});
it('updates paused VOD seekability when progress fires', () => {
const video = createVideo({
duration: 120,
paused: true,
seekableLength: 0,
});
adapter.attach(video, { isLive: () => false });
expect(adapter.state().canSeek).toBe(false);
setMediaProp(video, 'seekable', { length: 1 });
video.dispatchEvent(new Event('progress'));
expect(adapter.state().canSeek).toBe(true);
});
it.each(['loadstart', 'stalled', 'seeking', 'seeked'] as const)(
'invalidates cached media state on %s',
(eventName) => {
const video = createVideo({ currentTime: 0 });
adapter.attach(video, { isLive: () => false });
expect(adapter.state().positionSeconds).toBe(0);
setMediaProp(video, 'currentTime', 15);
video.dispatchEvent(new Event(eventName));
expect(adapter.state().positionSeconds).toBe(15);
}
);
});
@@ -0,0 +1,393 @@
import { signal } from '@angular/core';
import type { PlayerTrack } from './player-controls.model';
import { WebVideoControlsAdapter } from './web-video-controls.adapter';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
/**
* Builds a jsdom `<video>` whose readonly media props (duration, readyState,
* paused, ended, error, seekable) can be overridden per-test.
*/
function createVideo(
overrides: Partial<{
duration: number;
readyState: number;
networkState: number;
paused: boolean;
ended: boolean;
error: MediaError | null;
seekableLength: number;
muted: boolean;
volume: number;
currentTime: number;
playbackRate: number;
}> = {}
): HTMLVideoElement {
const video = document.createElement('video');
const define = (prop: string, value: unknown) =>
Object.defineProperty(video, prop, {
configurable: true,
writable: true,
value,
});
define('duration', overrides.duration ?? NaN);
define('readyState', overrides.readyState ?? 4);
define('networkState', overrides.networkState ?? 1);
define('paused', overrides.paused ?? false);
define('ended', overrides.ended ?? false);
define('error', overrides.error ?? null);
define('seekable', { length: overrides.seekableLength ?? 0 });
define('muted', overrides.muted ?? false);
define('volume', overrides.volume ?? 1);
define('currentTime', overrides.currentTime ?? 0);
define('playbackRate', overrides.playbackRate ?? 1);
define('play', jest.fn().mockResolvedValue(undefined));
define('pause', jest.fn());
return video;
}
describe('WebVideoControlsAdapter', () => {
let adapter: WebVideoControlsAdapter;
beforeEach(() => {
adapter = new WebVideoControlsAdapter();
});
afterEach(() => adapter.detach());
it('maps a playing VOD video to state with seek + finite duration', () => {
const video = createVideo({
duration: 120,
seekableLength: 1,
currentTime: 30,
volume: 0.6,
});
adapter.attach(video, { isLive: () => false });
const state = adapter.state();
expect(state.status).toBe('playing');
expect(state.positionSeconds).toBe(30);
expect(state.durationSeconds).toBe(120);
expect(state.isLive).toBe(false);
expect(state.canSeek).toBe(true);
expect(state.volume).toBe(0.6);
});
it('reports a paused video and a muted volume of 0', () => {
const video = createVideo({ paused: true, muted: true, volume: 0.8 });
adapter.attach(video, { isLive: () => false });
const state = adapter.state();
expect(state.status).toBe('paused');
expect(state.volume).toBe(0);
});
it('treats infinite-duration sources as live with no seek', () => {
const video = createVideo({ duration: Infinity });
adapter.attach(video);
const state = adapter.state();
expect(state.isLive).toBe(true);
expect(state.durationSeconds).toBeNull();
expect(state.canSeek).toBe(false);
});
it('prefers getDuration over the element duration (raw-TS VOD)', () => {
// mpegts VOD: <video>.duration stays Infinity, real duration on player.
const video = createVideo({ duration: Infinity, seekableLength: 1 });
adapter.attach(video, { getDuration: () => 164.072 });
const state = adapter.state();
expect(state.isLive).toBe(false);
expect(state.durationSeconds).toBe(164.072);
expect(state.canSeek).toBe(true);
});
it('falls back to the element duration when getDuration returns NaN', () => {
const video = createVideo({ duration: 90, seekableLength: 1 });
adapter.attach(video, { getDuration: () => NaN });
const state = adapter.state();
expect(state.isLive).toBe(false);
expect(state.durationSeconds).toBe(90);
});
it('is not stalled when paused with partial data, but stalls while buffering', () => {
const paused = createVideo({
paused: true,
readyState: 2,
duration: 120,
});
adapter.attach(paused, { isLive: () => false });
expect(adapter.state().stalled).toBe(false);
const buffering = createVideo({
paused: false,
ended: false,
readyState: 2,
duration: 120,
});
const bufferingAdapter = new WebVideoControlsAdapter();
bufferingAdapter.attach(buffering, { isLive: () => false });
expect(bufferingAdapter.state().stalled).toBe(true);
bufferingAdapter.detach();
});
it('maps error and ended status from the video element', () => {
const errorVideo = createVideo({
error: { code: 4 } as MediaError,
});
adapter.attach(errorVideo);
expect(adapter.state().status).toBe('error');
const endedAdapter = new WebVideoControlsAdapter();
endedAdapter.attach(createVideo({ ended: true }));
expect(endedAdapter.state().status).toBe('ended');
endedAdapter.detach();
});
it('recomputes state when media events fire', () => {
const video = createVideo({ paused: true });
adapter.attach(video, { isLive: () => false });
expect(adapter.state().status).toBe('paused');
Object.defineProperty(video, 'paused', {
configurable: true,
value: false,
});
video.dispatchEvent(new Event('play'));
expect(adapter.state().status).toBe('playing');
});
it('refreshes cached engine getter state only when explicitly invalidated', () => {
let audioTracks: PlayerTrack[] = [
{ id: 0, label: 'English', selected: true },
];
let subtitleTracks: PlayerTrack[] = [];
let duration = 90;
let live = false;
const video = createVideo({ duration: 90, seekableLength: 1 });
adapter.attach(video, {
getAudioTracks: () => audioTracks,
setAudioTrack: jest.fn(),
getSubtitleTracks: () => subtitleTracks,
setSubtitleTrack: jest.fn(),
getDuration: () => duration,
isLive: () => live,
});
expect(adapter.capabilities().audioTracks).toBe(false);
expect(adapter.capabilities().subtitles).toBe(false);
expect(adapter.state().durationSeconds).toBe(90);
expect(adapter.state().isLive).toBe(false);
audioTracks = [
{ id: 0, label: 'English', selected: true },
{ id: 1, label: 'German', selected: false },
];
subtitleTracks = [{ id: 2, label: 'English', selected: true }];
duration = 180;
live = true;
expect(adapter.capabilities().audioTracks).toBe(false);
expect(adapter.capabilities().subtitles).toBe(false);
expect(adapter.state().durationSeconds).toBe(90);
expect(adapter.state().isLive).toBe(false);
adapter.refresh();
expect(adapter.capabilities().audioTracks).toBe(true);
expect(adapter.capabilities().subtitles).toBe(true);
expect(adapter.state().durationSeconds).toBeNull();
expect(adapter.state().isLive).toBe(true);
expect(adapter.state().audioTracks).toEqual(audioTracks);
expect(adapter.state().subtitleTracks).toEqual(subtitleTracks);
});
it('delegates play/pause/seek/volume/speed commands to the element', () => {
const video = createVideo({ duration: 100, paused: true });
adapter.attach(video, { isLive: () => false });
adapter.commands.togglePlay();
expect(video.play).toHaveBeenCalled();
adapter.commands.seekTo(42);
expect(video.currentTime).toBe(42);
adapter.commands.seekBy(10);
expect(video.currentTime).toBe(52);
adapter.commands.setVolume(0.25);
expect(video.volume).toBe(0.25);
expect(video.muted).toBe(false);
adapter.commands.setVolume(0);
expect(video.muted).toBe(true);
adapter.commands.setPlaybackSpeed(1.5);
expect(video.playbackRate).toBe(1.5);
});
it('clamps seekTo to the finite duration', () => {
const video = createVideo({ duration: 60 });
adapter.attach(video, { isLive: () => false });
adapter.commands.seekTo(999);
expect(video.currentTime).toBe(60);
adapter.commands.seekTo(-5);
expect(video.currentTime).toBe(0);
});
it('gates capabilities: recording always false, aspectRatio false', () => {
adapter.attach(createVideo({ duration: 100 }), {
isLive: () => false,
});
const caps = adapter.capabilities();
expect(caps.recording).toBe(false);
expect(caps.aspectRatio).toBe(false);
expect(caps.seek).toBe(true);
expect(caps.volume).toBe(true);
expect(caps.playbackSpeed).toBe(true);
expect(caps.fullscreen).toBe(true);
});
it('enables audioTracks only when more than one track is exposed', () => {
const single: PlayerTrack[] = [{ id: 0, label: 'EN', selected: true }];
const multi: PlayerTrack[] = [
{ id: 0, label: 'EN', selected: true },
{ id: 1, label: 'DE', selected: false },
];
adapter.attach(createVideo(), {
getAudioTracks: () => single,
setAudioTrack: jest.fn(),
});
expect(adapter.capabilities().audioTracks).toBe(false);
const multiAdapter = new WebVideoControlsAdapter();
multiAdapter.attach(createVideo(), {
getAudioTracks: () => multi,
setAudioTrack: jest.fn(),
});
expect(multiAdapter.capabilities().audioTracks).toBe(true);
expect(multiAdapter.state().audioTracks).toEqual(multi);
multiAdapter.detach();
});
it('enables subtitles when any subtitle track is present', () => {
const subtitles: PlayerTrack[] = [
{ id: 0, label: 'EN', selected: true },
];
adapter.attach(createVideo(), {
getSubtitleTracks: () => subtitles,
setSubtitleTrack: jest.fn(),
});
expect(adapter.capabilities().subtitles).toBe(true);
expect(adapter.state().subtitlesEnabled).toBe(true);
});
it('refreshes track state after synchronous selection commands', () => {
let audioTracks: PlayerTrack[] = [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
];
let subtitleTracks: PlayerTrack[] = [
{ id: 3, label: 'English', selected: false },
];
const setAudioTrack = jest.fn((id: number) => {
audioTracks = audioTracks.map((track) => ({
...track,
selected: track.id === id,
}));
});
const setSubtitleTrack = jest.fn((id: number) => {
subtitleTracks = subtitleTracks.map((track) => ({
...track,
selected: track.id === id,
}));
});
adapter.attach(createVideo(), {
getAudioTracks: () => audioTracks,
setAudioTrack,
getSubtitleTracks: () => subtitleTracks,
setSubtitleTrack,
});
expect(adapter.state().audioTracks[0].selected).toBe(true);
expect(adapter.state().subtitlesEnabled).toBe(false);
adapter.commands.setAudioTrack(2);
adapter.commands.setSubtitleTrack(3);
expect(setAudioTrack).toHaveBeenCalledWith(2);
expect(setSubtitleTrack).toHaveBeenCalledWith(3);
expect(adapter.state().audioTracks[1].selected).toBe(true);
expect(adapter.state().subtitlesEnabled).toBe(true);
});
it('gates series navigation on context + VOD', () => {
const nav = signal<SeriesPlaybackNavigation | null>({
canPrevious: true,
canNext: false,
autoplayEnabled: true,
});
const video = createVideo({ duration: 100 });
adapter.attach(video, { isLive: () => false });
adapter.setContext({ seriesNavigation: nav });
expect(adapter.capabilities().seriesNavigation).toBe(true);
expect(adapter.state().canPreviousEpisode).toBe(true);
expect(adapter.state().canNextEpisode).toBe(false);
});
it('reflects host series-navigation signal updates made AFTER setContext', () => {
const nav = signal<SeriesPlaybackNavigation | null>({
canPrevious: false,
canNext: false,
autoplayEnabled: true,
});
const video = createVideo({ duration: 100 });
adapter.attach(video, { isLive: () => false });
adapter.setContext({ seriesNavigation: nav });
expect(adapter.state().canPreviousEpisode).toBe(false);
expect(adapter.state().canNextEpisode).toBe(false);
// Host pushes an update to the SAME signal after setContext.
nav.set({ canPrevious: true, canNext: true, autoplayEnabled: true });
expect(adapter.capabilities().seriesNavigation).toBe(true);
expect(adapter.state().canPreviousEpisode).toBe(true);
expect(adapter.state().canNextEpisode).toBe(true);
// Clearing the navigation reactively disables the capability.
nav.set(null);
expect(adapter.capabilities().seriesNavigation).toBe(false);
});
it('disables series navigation for live streams', () => {
const nav = signal<SeriesPlaybackNavigation | null>({
canPrevious: true,
canNext: true,
autoplayEnabled: true,
});
adapter.attach(createVideo({ duration: Infinity }), {
isLive: () => true,
});
adapter.setContext({ seriesNavigation: nav });
expect(adapter.capabilities().seriesNavigation).toBe(false);
expect(adapter.state().canPreviousEpisode).toBe(false);
expect(adapter.state().canNextEpisode).toBe(false);
});
it('removes listeners on detach', () => {
const video = createVideo();
const removeSpy = jest.spyOn(video, 'removeEventListener');
adapter.attach(video);
adapter.detach();
expect(removeSpy).toHaveBeenCalledWith('play', expect.any(Function));
expect(removeSpy).toHaveBeenCalledWith(
'timeupdate',
expect.any(Function)
);
});
});
@@ -0,0 +1,127 @@
import type { PlayerTrack } from './player-controls.model';
import { WebVideoControlsAdapter } from './web-video-controls.adapter';
interface Deferred {
promise: Promise<void>;
resolve: () => void;
reject: (reason: unknown) => void;
}
function createDeferred(): Deferred {
let resolvePromise!: () => void;
let rejectPromise!: (reason: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
return {
promise,
resolve: resolvePromise,
reject: rejectPromise,
};
}
function createVideo(): HTMLVideoElement {
const video = document.createElement('video');
Object.defineProperties(video, {
duration: {
configurable: true,
value: 120,
},
readyState: {
configurable: true,
value: 4,
},
networkState: {
configurable: true,
value: 1,
},
paused: {
configurable: true,
value: true,
},
seekable: {
configurable: true,
value: { length: 1 },
},
});
return video;
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('WebVideoControlsAdapter async track commands', () => {
let adapter: WebVideoControlsAdapter;
beforeEach(() => {
adapter = new WebVideoControlsAdapter();
});
afterEach(() => adapter.detach());
it('refreshes track state only after an async setter fulfills', async () => {
let tracks: PlayerTrack[] = [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
];
const deferred = createDeferred();
const setAudioTrack = jest.fn(() => deferred.promise);
adapter.attach(createVideo(), {
getAudioTracks: () => tracks,
setAudioTrack,
});
expect(adapter.state().audioTracks[0].selected).toBe(true);
adapter.commands.setAudioTrack(2);
tracks = tracks.map((track) => ({
...track,
selected: track.id === 2,
}));
expect(adapter.state().audioTracks[0].selected).toBe(true);
deferred.resolve();
await flushPromises();
expect(setAudioTrack).toHaveBeenCalledWith(2);
expect(adapter.state().audioTracks[1].selected).toBe(true);
});
it('swallows an async setter rejection without refreshing state', async () => {
const tracks: PlayerTrack[] = [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
];
const deferred = createDeferred();
const setAudioTrack = jest.fn(() => deferred.promise);
adapter.attach(createVideo(), {
getAudioTracks: () => tracks,
setAudioTrack,
});
const stateBefore = adapter.state();
adapter.commands.setAudioTrack(2);
deferred.reject(new Error('engine rejected selection'));
await flushPromises();
expect(setAudioTrack).toHaveBeenCalledWith(2);
expect(adapter.state()).toBe(stateBefore);
});
it('swallows a synchronous setter exception', () => {
const setSubtitleTrack = jest.fn(() => {
throw new Error('engine changing source');
});
adapter.attach(createVideo(), {
getSubtitleTracks: () => [
{ id: 3, label: 'English', selected: false },
],
setSubtitleTrack,
});
expect(() => adapter.commands.setSubtitleTrack(3)).not.toThrow();
expect(setSubtitleTrack).toHaveBeenCalledWith(3);
});
});
@@ -0,0 +1,366 @@
import { Injectable, type Signal, computed, signal } from '@angular/core';
import {
createEmptyControlsState,
DEFAULT_ASPECT_PRESETS,
DEFAULT_PLAYER_CAPABILITIES,
DEFAULT_SPEED_PRESETS,
} from './player-controls-defaults';
import type {
PlayerController,
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerStatus,
PlayerTrack,
} from './player-controls.model';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
/**
* Engine-agnostic accessors a web engine injects so the adapter can read/select
* audio & subtitle tracks without importing hls.js/videojs/artplayer.
*/
export interface WebVideoControlsOptions {
getAudioTracks?: () => PlayerTrack[];
setAudioTrack?: (id: number) => void | Promise<void>;
getSubtitleTracks?: () => PlayerTrack[];
setSubtitleTrack?: (id: number) => void | Promise<void>;
isLive?: () => boolean;
/**
* Optional corrected duration source. Some engines (e.g. Video.js mpegts
* raw-TS VOD) report the real duration on the player, not on the `<video>`
* element — whose `duration` stays `Infinity` and would be misread as live.
* Every returned value except `NaN` is authoritative; `NaN` falls back to
* `video.duration`.
*/
getDuration?: () => number;
}
/** readyState below which an actively-playing video is still buffering. */
const HAVE_FUTURE_DATA = 3;
const NETWORK_EMPTY = 0;
interface WebVideoControlsContext {
seriesNavigation: Signal<SeriesPlaybackNavigation | null>;
}
const VIDEO_EVENTS = [
'loadstart',
'emptied',
'progress',
'stalled',
'seeking',
'seeked',
'play',
'pause',
'timeupdate',
'durationchange',
'volumechange',
'ratechange',
'loadedmetadata',
'loadeddata',
'canplay',
'ended',
'waiting',
'playing',
'error',
] as const;
/**
* Can bridge any `<video>`-backed web engine onto the generic
* {@link PlayerController} contract consumed by `app-player-controls`. It binds
* purely to DOM/video APIs (works in PWA — no window.electron). Track access is
* injected via {@link WebVideoControlsOptions} to keep it engine-agnostic.
* Existing web players do not attach this adapter in #1148.
*/
@Injectable()
export class WebVideoControlsAdapter implements PlayerController {
private video: HTMLVideoElement | null = null;
private opts: WebVideoControlsOptions = {};
private detachFn: (() => void) | null = null;
/** Bumped whenever DOM or engine-specific state must be re-read. */
private readonly tick = signal(0);
/**
* Holds the host-supplied series-navigation signal reactively. Storing the
* signal itself (rather than a one-time snapshot of its value) means updates
* the host pushes AFTER {@link setContext} are reflected here.
*/
private readonly seriesNavigationSource = signal<
Signal<SeriesPlaybackNavigation | null>
>(signal(null));
private readonly seriesNavigation = computed(() =>
this.seriesNavigationSource()()
);
readonly capabilities = computed<PlayerControlsCapabilities>(() => {
this.tick();
if (!this.video) {
return DEFAULT_PLAYER_CAPABILITIES;
}
const hasAudioTracks =
typeof this.opts.setAudioTrack === 'function' &&
(this.opts.getAudioTracks?.().length ?? 0) > 1;
const hasSubtitles =
typeof this.opts.setSubtitleTrack === 'function' &&
(this.opts.getSubtitleTracks?.().length ?? 0) > 0;
const isLive = this.isLive();
return {
...DEFAULT_PLAYER_CAPABILITIES,
seek: !isLive,
volume: true,
playbackSpeed: true,
fullscreen: true,
audioTracks: hasAudioTracks,
subtitles: hasSubtitles,
aspectRatio: false,
recording: false,
seriesNavigation: !isLive && this.seriesNavigation() !== null,
};
});
readonly state = computed<PlayerControlsState>(() => {
this.tick();
const video = this.video;
if (!video) {
return createEmptyControlsState();
}
const isLive = this.isLive();
const duration = this.normalizedDuration(isLive);
const seriesNav = this.seriesNavigation();
const seriesNavCapable = !isLive && seriesNav !== null;
const audioTracks = this.opts.getAudioTracks?.() ?? [];
const subtitleTracks = this.opts.getSubtitleTracks?.() ?? [];
return {
status: this.mapStatus(video),
statusMessage: '',
stalled: this.isStalled(video),
positionSeconds: Math.max(0, video?.currentTime ?? 0),
durationSeconds: duration,
isLive,
canSeek: !isLive && (duration ?? 0) > 0 && this.hasSeekable(video),
volume: this.readVolume(video),
audioTracks,
subtitleTracks,
subtitlesEnabled: subtitleTracks.some((track) => track.selected),
playbackSpeed: video?.playbackRate ?? 1,
speedPresets: DEFAULT_SPEED_PRESETS,
aspectRatio: 'no',
aspectPresets: DEFAULT_ASPECT_PRESETS,
recording: { active: false, elapsedSeconds: 0, message: null },
canPreviousEpisode:
seriesNavCapable && seriesNav?.canPrevious === true,
canNextEpisode: seriesNavCapable && seriesNav?.canNext === true,
};
});
readonly commands: PlayerControlsCommands = {
togglePlay: () => this.togglePlay(),
seekTo: (seconds) => this.applyCurrentTime(seconds),
seekBy: (delta) =>
this.applyCurrentTime((this.video?.currentTime ?? 0) + delta),
setVolume: (value) => this.applyVolume(value),
setAudioTrack: (id) =>
this.applyTrackSelection(this.opts.setAudioTrack, id),
setSubtitleTrack: (id) =>
this.applyTrackSelection(this.opts.setSubtitleTrack, id),
setPlaybackSpeed: (speed) => this.applySpeed(speed),
setAspectRatio: () => undefined,
toggleRecording: () => undefined,
};
/** Binds to a `<video>` element and starts maintaining the state signal. */
attach(video: HTMLVideoElement, opts: WebVideoControlsOptions = {}): void {
this.detach();
this.video = video;
this.opts = opts;
const onEvent = () => this.refresh();
for (const eventName of VIDEO_EVENTS) {
video.addEventListener(eventName, onEvent);
}
this.detachFn = () => {
for (const eventName of VIDEO_EVENTS) {
video.removeEventListener(eventName, onEvent);
}
};
// Prime the initial snapshot.
onEvent();
}
/**
* Invalidates cached state/capabilities after an engine-specific getter
* changes without emitting a native media event (for example tracks,
* corrected duration, or live/VOD classification).
*/
refresh(): void {
this.tick.update((value) => value + 1);
}
/** Removes listeners and clears the bound element. Idempotent. */
detach(): void {
this.detachFn?.();
this.detachFn = null;
this.video = null;
this.opts = {};
this.refresh();
}
/** Pushes reactive context (series navigation) used for capability gating. */
setContext(context: WebVideoControlsContext): void {
this.seriesNavigationSource.set(context.seriesNavigation);
}
private togglePlay(): void {
const video = this.video;
if (!video) {
return;
}
if (video.paused || video.ended) {
void video.play().catch(() => undefined);
} else {
video.pause();
}
}
private applyCurrentTime(seconds: number): void {
const video = this.video;
if (!video || !Number.isFinite(seconds)) {
return;
}
const duration = this.readDuration();
const upperBound = Number.isFinite(duration) ? duration : seconds;
try {
video.currentTime = Math.max(0, Math.min(seconds, upperBound));
} catch {
// Some media implementations reject writes while changing source.
}
}
private applyVolume(value: number): void {
const video = this.video;
if (!video || !Number.isFinite(value)) {
return;
}
const clamped = Math.max(0, Math.min(1, value));
try {
video.volume = clamped;
video.muted = clamped <= 0;
} catch {
// Ignore transient native media setter failures.
}
}
private applySpeed(speed: number): void {
if (this.video && Number.isFinite(speed) && speed > 0) {
try {
this.video.playbackRate = speed;
} catch {
// Ignore transient native media setter failures.
}
}
}
private applyTrackSelection(
setter: ((id: number) => void | Promise<void>) | undefined,
id: number
): void {
if (!setter) {
return;
}
try {
const result = setter(id);
if (result) {
void result.then(
() => this.refresh(),
() => undefined
);
return;
}
this.refresh();
} catch {
// Engine adapters may reject selection while changing source.
}
}
private isLive(): boolean {
if (this.opts.isLive) {
return this.opts.isLive();
}
return this.readDuration() === Number.POSITIVE_INFINITY;
}
/**
* Reads the corrected duration via {@link WebVideoControlsOptions.getDuration}
* when supplied. `NaN` means "not known yet" and falls back to the
* `<video>` element's own `duration`; positive Infinity remains a live
* classification signal.
*/
private readDuration(): number {
if (this.opts.getDuration) {
const duration = this.opts.getDuration();
if (!Number.isNaN(duration)) {
return duration;
}
}
return this.video?.duration ?? NaN;
}
/**
* A video is only "stalled" when it is actively trying to play and lacks
* future data; a normally paused VOD must not show a spinner.
*/
private isStalled(video: HTMLVideoElement | null): boolean {
if (!video || video.readyState === undefined) {
return false;
}
return (
!video.paused && !video.ended && video.readyState < HAVE_FUTURE_DATA
);
}
private normalizedDuration(isLive: boolean): number | null {
const duration = this.readDuration();
if (isLive || !Number.isFinite(duration) || duration <= 0) {
return null;
}
return duration;
}
private hasSeekable(video: HTMLVideoElement | null): boolean {
try {
return (video?.seekable?.length ?? 0) > 0;
} catch {
return false;
}
}
private readVolume(video: HTMLVideoElement | null): number {
if (!video) {
return 1;
}
return video.muted ? 0 : video.volume;
}
private mapStatus(video: HTMLVideoElement | null): PlayerStatus {
if (!video) {
return 'idle';
}
if (video.error) {
return 'error';
}
if (video.ended) {
return 'ended';
}
if (video.networkState === NETWORK_EMPTY) {
return 'idle';
}
if (video.paused) {
return 'paused';
}
return video.readyState < 3 ? 'loading' : 'playing';
}
}
@@ -0,0 +1,60 @@
import type { WebVideoControlsAdapter } from './web-video-controls.adapter';
import {
attachWebVideoControls,
toPlayerTracks,
} from './web-video-controls.host';
describe('attachWebVideoControls', () => {
it('attaches the adapter to the video with the supplied options', () => {
const adapter = { attach: jest.fn() } as unknown as jest.Mocked<
Pick<WebVideoControlsAdapter, 'attach'>
>;
const video = document.createElement('video');
const options = { isLive: () => true };
attachWebVideoControls({
video,
adapter: adapter as unknown as WebVideoControlsAdapter,
options,
});
expect(adapter.attach).toHaveBeenCalledWith(video, options);
});
it('defaults omitted options to an empty object', () => {
const adapter = { attach: jest.fn() } as unknown as jest.Mocked<
Pick<WebVideoControlsAdapter, 'attach'>
>;
const video = document.createElement('video');
attachWebVideoControls({
video,
adapter: adapter as unknown as WebVideoControlsAdapter,
});
expect(adapter.attach).toHaveBeenCalledWith(video, {});
});
});
describe('toPlayerTracks', () => {
it('projects entries onto plain PlayerTrack objects', () => {
const entries = [
{ id: 0, label: 'English', selected: true, extra: 'ignored' },
{ id: 1, label: 'German', selected: false },
];
const tracks = toPlayerTracks(entries);
expect(tracks).toEqual([
{ id: 0, label: 'English', selected: true },
{ id: 1, label: 'German', selected: false },
]);
// New objects, no engine-specific extras leaking through.
expect(tracks[0]).not.toBe(entries[0]);
expect('extra' in tracks[0]).toBe(false);
});
it('maps an empty list to an empty list', () => {
expect(toPlayerTracks([])).toEqual([]);
});
});
@@ -0,0 +1,44 @@
import type { PlayerTrack } from './player-controls.model';
import type {
WebVideoControlsAdapter,
WebVideoControlsOptions,
} from './web-video-controls.adapter';
/**
* Minimal glue a follow-up web-player integration can call to wire its `<video>`
* element and engine track accessors onto a {@link WebVideoControlsAdapter}.
* No existing player calls this helper in #1148.
*/
export interface WebVideoControlsHostConfig {
/** The `<video>` element the active engine is rendering into. */
video: HTMLVideoElement;
adapter: WebVideoControlsAdapter;
/** Engine-specific track accessors; omit what the engine does not expose. */
options?: WebVideoControlsOptions;
}
/** Attaches the adapter to the video element with the supplied accessors. */
export function attachWebVideoControls(
config: WebVideoControlsHostConfig
): void {
config.adapter.attach(config.video, config.options ?? {});
}
/**
* Maps a list of `{ id, label, selected }`-like entries to {@link PlayerTrack}.
* Engines that already expose plain track lists can reuse this to avoid
* duplicating the projection.
*/
export function toPlayerTracks(
entries: ReadonlyArray<{
id: number;
label: string;
selected: boolean;
}>
): PlayerTrack[] {
return entries.map((entry) => ({
id: entry.id,
label: entry.label,
selected: entry.selected,
}));
}