mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
62 files changed
+7328
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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)**:
|
||||
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
"BACK_10_SECONDS": "رجوع 10 ثوانٍ",
|
||||
"FORWARD_10_SECONDS": "تقديم 10 ثوانٍ",
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"PLAYBACK_POSITION": "موضع التشغيل",
|
||||
"MUTE": "كتم الصوت",
|
||||
"UNMUTE": "إلغاء كتم الصوت",
|
||||
"MUTED": "الصوت مكتوم",
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
"BACK_10_SECONDS": "رجع 10 ثواني",
|
||||
"FORWARD_10_SECONDS": "قدّم 10 ثواني",
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"PLAYBACK_POSITION": "موضع التشغيل",
|
||||
"MUTE": "كتم الصوت",
|
||||
"UNMUTE": "رجّع الصوت",
|
||||
"MUTED": "الصوت مكتوم",
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
"BACK_10_SECONDS": "Назад на 10 секунд",
|
||||
"FORWARD_10_SECONDS": "Наперад на 10 секунд",
|
||||
"LIVE_STREAM": "Прамая трансляцыя",
|
||||
"PLAYBACK_POSITION": "Пазіцыя прайгравання",
|
||||
"MUTE": "Адключыць гук",
|
||||
"UNMUTE": "Уключыць гук",
|
||||
"MUTED": "Гук адключаны",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
"BACK_10_SECONDS": "Πίσω 10 δευτερόλεπτα",
|
||||
"FORWARD_10_SECONDS": "Μπροστά 10 δευτερόλεπτα",
|
||||
"LIVE_STREAM": "Ζωντανή ροή",
|
||||
"PLAYBACK_POSITION": "Θέση αναπαραγωγής",
|
||||
"MUTE": "Σίγαση",
|
||||
"UNMUTE": "Κατάργηση σίγασης",
|
||||
"MUTED": "Σε σίγαση",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
"BACK_10_SECONDS": "10秒戻る",
|
||||
"FORWARD_10_SECONDS": "10秒進む",
|
||||
"LIVE_STREAM": "ライブストリーム",
|
||||
"PLAYBACK_POSITION": "再生位置",
|
||||
"MUTE": "ミュート",
|
||||
"UNMUTE": "ミュート解除",
|
||||
"MUTED": "ミュート中",
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
"BACK_10_SECONDS": "10초 뒤로",
|
||||
"FORWARD_10_SECONDS": "10초 앞으로",
|
||||
"LIVE_STREAM": "라이브 스트림",
|
||||
"PLAYBACK_POSITION": "재생 위치",
|
||||
"MUTE": "음소거",
|
||||
"UNMUTE": "음소거 해제",
|
||||
"MUTED": "음소거됨",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
"BACK_10_SECONDS": "Назад на 10 секунд",
|
||||
"FORWARD_10_SECONDS": "Вперёд на 10 секунд",
|
||||
"LIVE_STREAM": "Прямой эфир",
|
||||
"PLAYBACK_POSITION": "Позиция воспроизведения",
|
||||
"MUTE": "Отключить звук",
|
||||
"UNMUTE": "Включить звук",
|
||||
"MUTED": "Звук отключён",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
"BACK_10_SECONDS": "快退 10 秒",
|
||||
"FORWARD_10_SECONDS": "快进 10 秒",
|
||||
"LIVE_STREAM": "直播",
|
||||
"PLAYBACK_POSITION": "播放位置",
|
||||
"MUTE": "静音",
|
||||
"UNMUTE": "取消静音",
|
||||
"MUTED": "已静音",
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
}
|
||||
+370
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
+127
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user