From aa6ee85d3f718b41cec3ab7837b028510273915c Mon Sep 17 00:00:00 2001 From: Lars Emig Date: Thu, 16 Jul 2026 12:56:30 +0200 Subject: [PATCH] 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 * 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 Co-authored-by: 4gray --- AGENTS.md | 13 + CLAUDE.md | 1 + apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + docs/architecture/player-controls-contract.md | 331 ++++++++++ libs/ui/playback/src/index.ts | 1 + .../player-controls/controls-feedback.spec.ts | 59 ++ .../lib/player-controls/controls-feedback.ts | 61 ++ .../controls-format.utils.spec.ts | 41 ++ .../player-controls/controls-format.utils.ts | 43 ++ .../controls-fullscreen.spec.ts | 82 +++ .../player-controls/controls-fullscreen.ts | 65 ++ .../controls-menu-selection.spec.ts | 87 +++ .../controls-menu-selection.ts | 49 ++ .../controls-menu-state.spec.ts | 122 ++++ .../player-controls/controls-menu-state.ts | 121 ++++ .../controls-shortcuts.spec.ts | 266 ++++++++ .../lib/player-controls/controls-shortcuts.ts | 203 ++++++ .../player-controls/controls-surface.spec.ts | 179 ++++++ .../lib/player-controls/controls-surface.ts | 161 +++++ .../player-controls/controls-view-model.ts | 110 ++++ .../controls-visibility.spec.ts | 66 ++ .../player-controls/controls-visibility.ts | 62 ++ .../player-controls/controls-volume.spec.ts | 130 ++++ .../lib/player-controls/controls-volume.ts | 148 +++++ .../playback/src/lib/player-controls/index.ts | 15 + .../player-controls-defaults.ts | 57 ++ ...player-controls.component.contract.spec.ts | 272 ++++++++ .../player-controls.component.html | 599 ++++++++++++++++++ ...er-controls.component.interactions.spec.ts | 370 +++++++++++ .../player-controls.component.scss | 469 ++++++++++++++ .../player-controls.component.spec.ts | 376 +++++++++++ .../player-controls.component.surface.spec.ts | 400 ++++++++++++ ...player-controls.component.timeline.spec.ts | 208 ++++++ .../player-controls.component.ts | 399 ++++++++++++ .../player-controls/player-controls.model.ts | 89 +++ .../web-player-controls.flag.spec.ts | 29 + .../web-player-controls.flag.ts | 24 + ...eb-video-controls.adapter.commands.spec.ts | 319 ++++++++++ ...eb-video-controls.adapter.contract.spec.ts | 154 +++++ .../web-video-controls.adapter.events.spec.ts | 139 ++++ .../web-video-controls.adapter.spec.ts | 393 ++++++++++++ ...eo-controls.adapter.track-commands.spec.ts | 127 ++++ .../web-video-controls.adapter.ts | 366 +++++++++++ .../web-video-controls.host.spec.ts | 60 ++ .../web-video-controls.host.ts | 44 ++ 62 files changed, 7328 insertions(+) create mode 100644 docs/architecture/player-controls-contract.md create mode 100644 libs/ui/playback/src/lib/player-controls/controls-feedback.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-feedback.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-format.utils.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-fullscreen.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-fullscreen.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-menu-state.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-surface.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-surface.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-view-model.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-visibility.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-visibility.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-volume.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-volume.ts create mode 100644 libs/ui/playback/src/lib/player-controls/index.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.html create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.scss create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.model.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-player-controls.flag.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-player-controls.flag.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.contract.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.events.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.track-commands.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.host.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.host.ts diff --git a/AGENTS.md b/AGENTS.md index 9937811d5..b3e3a3a14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/CLAUDE.md b/CLAUDE.md index a07c612d5..6ffeecc2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=` 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 ``, 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)**: diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 5477ac874..c2b746dc0 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -462,6 +462,7 @@ "BACK_10_SECONDS": "رجوع 10 ثوانٍ", "FORWARD_10_SECONDS": "تقديم 10 ثوانٍ", "LIVE_STREAM": "بث مباشر", + "PLAYBACK_POSITION": "موضع التشغيل", "MUTE": "كتم الصوت", "UNMUTE": "إلغاء كتم الصوت", "MUTED": "الصوت مكتوم", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 39e39a6a7..d4c8f9aed 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -462,6 +462,7 @@ "BACK_10_SECONDS": "رجع 10 ثواني", "FORWARD_10_SECONDS": "قدّم 10 ثواني", "LIVE_STREAM": "بث مباشر", + "PLAYBACK_POSITION": "موضع التشغيل", "MUTE": "كتم الصوت", "UNMUTE": "رجّع الصوت", "MUTED": "الصوت مكتوم", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 51187e9d6..ce6157a0c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -462,6 +462,7 @@ "BACK_10_SECONDS": "Назад на 10 секунд", "FORWARD_10_SECONDS": "Наперад на 10 секунд", "LIVE_STREAM": "Прамая трансляцыя", + "PLAYBACK_POSITION": "Пазіцыя прайгравання", "MUTE": "Адключыць гук", "UNMUTE": "Уключыць гук", "MUTED": "Гук адключаны", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8e6a996a3..9b81484d6 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a1771a6cb..9ab7b28cc 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -462,6 +462,7 @@ "BACK_10_SECONDS": "Πίσω 10 δευτερόλεπτα", "FORWARD_10_SECONDS": "Μπροστά 10 δευτερόλεπτα", "LIVE_STREAM": "Ζωντανή ροή", + "PLAYBACK_POSITION": "Θέση αναπαραγωγής", "MUTE": "Σίγαση", "UNMUTE": "Κατάργηση σίγασης", "MUTED": "Σε σίγαση", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b6b2b61b8..0ae94e68d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 427b38154..695aa7304 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d3fafc62c..328efd3c3 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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é", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index a330a03a5..fe89e649a 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index c31ed8ec1..526e50d78 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -462,6 +462,7 @@ "BACK_10_SECONDS": "10秒戻る", "FORWARD_10_SECONDS": "10秒進む", "LIVE_STREAM": "ライブストリーム", + "PLAYBACK_POSITION": "再生位置", "MUTE": "ミュート", "UNMUTE": "ミュート解除", "MUTED": "ミュート中", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 956c565f1..9c4896445 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -462,6 +462,7 @@ "BACK_10_SECONDS": "10초 뒤로", "FORWARD_10_SECONDS": "10초 앞으로", "LIVE_STREAM": "라이브 스트림", + "PLAYBACK_POSITION": "재생 위치", "MUTE": "음소거", "UNMUTE": "음소거 해제", "MUTED": "음소거됨", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 80ab1ee86..6f59ef527 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index efea31ddf..fc3a6f2dd 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index dae773b9b..56cae6e28 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 6d18a0d63..cf9809bbf 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -462,6 +462,7 @@ "BACK_10_SECONDS": "Назад на 10 секунд", "FORWARD_10_SECONDS": "Вперёд на 10 секунд", "LIVE_STREAM": "Прямой эфир", + "PLAYBACK_POSITION": "Позиция воспроизведения", "MUTE": "Отключить звук", "UNMUTE": "Включить звук", "MUTED": "Звук отключён", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f2c4a12c8..446aa5c17 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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ı", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 7ff6591eb..2b5fa958f 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -462,6 +462,7 @@ "BACK_10_SECONDS": "快退 10 秒", "FORWARD_10_SECONDS": "快进 10 秒", "LIVE_STREAM": "直播", + "PLAYBACK_POSITION": "播放位置", "MUTE": "静音", "UNMUTE": "取消静音", "MUTED": "已静音", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index e8b42f117..0e122c3fb 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -462,6 +462,7 @@ "BACK_10_SECONDS": "倒轉 10 秒", "FORWARD_10_SECONDS": "快轉 10 秒", "LIVE_STREAM": "現場直播", + "PLAYBACK_POSITION": "播放位置", "MUTE": "靜音", "UNMUTE": "取消靜音", "MUTED": "已靜音", diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md new file mode 100644 index 000000000..533446704 --- /dev/null +++ b/docs/architecture/player-controls-contract.md @@ -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 │ +│ capabilities: Signal │ +│ state: Signal │ +│ 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; + readonly state: Signal; + 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 +``. 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. diff --git a/libs/ui/playback/src/index.ts b/libs/ui/playback/src/index.ts index f3a4b4e7b..243a8d5cb 100644 --- a/libs/ui/playback/src/index.ts +++ b/libs/ui/playback/src/index.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/player-controls/controls-feedback.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-feedback.spec.ts new file mode 100644 index 000000000..308b84cce --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-feedback.spec.ts @@ -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'); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-feedback.ts b/libs/ui/playback/src/lib/player-controls/controls-feedback.ts new file mode 100644 index 000000000..f71ae6997 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-feedback.ts @@ -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; + } + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts new file mode 100644 index 000000000..95513efd2 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts @@ -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%'); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts b/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts new file mode 100644 index 000000000..14ee11dda --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts @@ -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)); +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-fullscreen.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-fullscreen.spec.ts new file mode 100644 index 000000000..480f98b39 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-fullscreen.spec.ts @@ -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(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-fullscreen.ts b/libs/ui/playback/src/lib/player-controls/controls-fullscreen.ts new file mode 100644 index 000000000..9893ae612 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-fullscreen.ts @@ -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 { + 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 + ); + } + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts new file mode 100644 index 000000000..bff239af4 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts @@ -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; + 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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts new file mode 100644 index 000000000..b676207ff --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts @@ -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(); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts new file mode 100644 index 000000000..dee25dfa2 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts @@ -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); + } + ); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts new file mode 100644 index 000000000..835ae1b15 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts @@ -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>; + +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; + } + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts new file mode 100644 index 000000000..64135ad46 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts @@ -0,0 +1,266 @@ +import { ControlsShortcuts } from './controls-shortcuts'; + +describe('ControlsShortcuts', () => { + let shortcuts: ControlsShortcuts; + let handlers: { + isAvailable: jest.Mock; + canTogglePaused: jest.Mock; + canSeek: jest.Mock; + canAdjustVolume: jest.Mock; + canToggleFullscreen: jest.Mock; + 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; +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts new file mode 100644 index 000000000..696925149 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts @@ -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(); + 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; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-surface.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-surface.spec.ts new file mode 100644 index 000000000..fdff8227c --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-surface.spec.ts @@ -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(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-surface.ts b/libs/ui/playback/src/lib/player-controls/controls-surface.ts new file mode 100644 index 000000000..e47a2e77e --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-surface.ts @@ -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 | 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; + } + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-view-model.ts b/libs/ui/playback/src/lib/player-controls/controls-view-model.ts new file mode 100644 index 000000000..9c3c74e9c --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-view-model.ts @@ -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; + capabilities: Signal; + volume: Signal; + isFullscreen: Signal; + canFullscreenNative: () => boolean; + showControls: Signal; + /** Whether the auto-hide layer currently wants the bar visible. */ + autoHideVisible: Signal; + anyMenuOpen: Signal; +} + +/** + * 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, + }; +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-visibility.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-visibility.spec.ts new file mode 100644 index 000000000..799b76955 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-visibility.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-visibility.ts b/libs/ui/playback/src/lib/player-controls/controls-visibility.ts new file mode 100644 index 000000000..37f3cab56 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-visibility.ts @@ -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(); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-volume.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-volume.spec.ts new file mode 100644 index 000000000..bfa521c86 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-volume.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-volume.ts b/libs/ui/playback/src/lib/player-controls/controls-volume.ts new file mode 100644 index 000000000..f9f79cfde --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-volume.ts @@ -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(); + 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; + } + } +} diff --git a/libs/ui/playback/src/lib/player-controls/index.ts b/libs/ui/playback/src/lib/player-controls/index.ts new file mode 100644 index 000000000..33c589d19 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/index.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts new file mode 100644 index 000000000..6073458dd --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts @@ -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> = [ + { 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> = [ + { 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, + }; +} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts new file mode 100644 index 000000000..44721fafc --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts @@ -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; + state: WritableSignal; + commands: jest.Mocked; +} + +function createFakeController(): FakeController { + const capabilities = signal({ ...DEFAULT_PLAYER_CAPABILITIES }); + const state = signal(createEmptyControlsState()); + const commands: jest.Mocked = { + 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[] = []; + 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 { + 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'); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html new file mode 100644 index 000000000..646e65058 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -0,0 +1,599 @@ +@if (feedback.current(); as fb) { + +} + +@if (showControls()) { +
+
+ + + @if (capabilities().seriesNavigation) { + + + + } + + @if (capabilities().seek) { + + + + } +
+ +
+ @if (capabilities().seek) { + + } +
+ {{ formatTime(timelineValue()) }} + @if (state().canSeek) { + {{ formatTime(timelineDuration()) }} + } @else if (state().isLive) { + + + LIVE + + } @else { + --:-- + } +
+ @if (recordingStatusText(); as recordingStatus) { +
+ {{ + isRecording() ? 'fiber_manual_record' : 'check_circle' + }} + {{ recordingStatus }} +
+ } +
+ +
+ @if (capabilities().volume) { +
+ + @if (menus.volumeOpen()) { + + } +
+ } + + @if (hasAudioTracks()) { +
+ + @if (menus.audioOpen()) { + + } +
+ } + + @if (hasSubtitleTracks()) { +
+ + @if (menus.subtitleOpen()) { + + } +
+ } + + @if (capabilities().playbackSpeed) { +
+ + @if (menus.speedOpen()) { + + } +
+ } + + @if (capabilities().aspectRatio) { +
+ + @if (menus.aspectOpen()) { + + } +
+ } + + @if (canRecord()) { + + } + + @if (capabilities().fullscreen) { + + } +
+
+} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts new file mode 100644 index 000000000..3bc3f388f --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts @@ -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 = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + 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; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + 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); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss new file mode 100644 index 000000000..c3de35ba9 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -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; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts new file mode 100644 index 000000000..f6c707629 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts @@ -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 = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + 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; + let fake: ReturnType; + + const setCapabilities = ( + overrides: Partial + ) => { + fake.capabilities.set({ + ...DEFAULT_PLAYER_CAPABILITIES, + ...overrides, + }); + }; + + const setState = (overrides: Partial) => { + 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; + 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); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts new file mode 100644 index 000000000..ed4bb8d1f --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts @@ -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 = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + 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; + let component: PlayerControlsComponent; + let fake: ReturnType; + let surface: HTMLElement; + let fullscreenElement: Element | null; + let requestFullscreen: jest.Mock; + let exitFullscreen: jest.Mock; + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + 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); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts new file mode 100644 index 000000000..fccff8128 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts @@ -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 = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + 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; + let fake: ReturnType; + + const setState = (overrides: Partial) => + 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'); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts new file mode 100644 index 000000000..2b350312c --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -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).nativeElement; + private readonly translate = inject(TranslateService); + readonly controller = input.required(); + readonly playerSurface = input(null); + readonly showControls = input(true); + readonly shortcutsEnabled = input(true); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); + 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(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 { + 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 + ); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts new file mode 100644 index 000000000..913ea4902 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts @@ -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 { + 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>; + aspectRatio: string; + aspectPresets: ReadonlyArray>; + 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; + readonly state: Signal; + readonly commands: PlayerControlsCommands; +} diff --git a/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.spec.ts b/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.spec.ts new file mode 100644 index 000000000..1b695b71b --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.ts b/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.ts new file mode 100644 index 000000000..5808a22ee --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.ts @@ -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( + 'WEB_PLAYER_SHARED_CONTROLS', + { + providedIn: 'root', + factory: () => WEB_PLAYER_SHARED_CONTROLS_ENABLED, + } +); diff --git a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts new file mode 100644 index 000000000..17e9bd2f5 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts @@ -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 `