From aa1941cf2ce0bf8cab0603016deb50396dfa23b5 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:39:59 +0200 Subject: [PATCH] fix(embedded-mpv): stop native-view video jump by moving control menus into the dock (#1207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(embedded-mpv): stop native-view video jump by moving menus into the dock Opening any control popover in the native-view embedded MPV dock used to shrink the MPV view by a 300 px bottom cutout so the popover DOM stayed clickable, which made mpv re-letterbox the video on every menu open/close. All five menus now render horizontally inside the fixed-height controls strip, so menu state never changes the native view bounds: - volume expands as an inline horizontal slider next to the mute button - audio/subtitle/speed/aspect morph the dock row into a back button, a panel title, and a scrollable chip ribbon (app-embedded-mpv-dock-panel) with wheel-to-horizontal-scroll mapping, edge fades, active-chip reveal/focus, roving arrow-key navigation, ellipsis + tooltips, and RTL-aware scrolling - boundsProvider loses the menus.anyOpen() cutout branch and the MENU_OPEN_BOTTOM_CUTOUT_PX constant is removed; HIDDEN_BOUNDS for modal overlays is unchanged - global arrow shortcuts (seek/volume) are suspended while a chip panel is open so arrows walk the chips; Esc, click-outside, and close-on-select semantics are preserved - new EMBEDDED_MPV.PLAYER.BACK i18n key in all 18 languages Regression coverage: the new dock-panels spec asserts the bounds provider returns full host bounds while every menu is open (fails against the old cutout behavior), plus panel morph/a11y/selection specs and a dedicated dock-panel component spec (keyboard, wheel, tabindex, emits). Frame-copy shared controls (app-player-controls) are untouched. Co-Authored-By: Claude Fable 5 * fix(embedded-mpv): address native-view dock review feedback Resolves the actionable P2 review comments on the dock rework: - Inline volume no longer clips the dock actions. At sidebar-constrained player widths (~480-660px, viewport wider than the 720px breakpoint) the new in-flow volume slider widened the non-shrinking actions column and, under overflow:hidden, clipped the fullscreen button. Add min-width:0 to .embedded-mpv-player__actions and __volume-group so the inline volume (a scroll container) compresses its own slider instead of pushing neighbors off-edge. Verified in Chromium: fullscreen stays visible down to 480px. - Space now selects a focused chip. onPanelKeydown stops Space/Enter from bubbling to the global shortcut handler (whose Space case preventDefault'd the button's native activation and toggled playback) without calling preventDefault itself, so the menuitemradio chip activates and emits chipSelected. Matches the WAI-ARIA menu activation-key expectation. - Simplify dock-panel opener tracking: always remember the toggled kind so focus restoration is correct if in-panel switching ever becomes reachable (currently unreachable — the toggle buttons are removed from the DOM while a panel is open); restoreOpenerFocus still no-ops unless focus fell to body. Not changed: the "closePanels no-ops when unavailable" comment — verified unreachable (chip selection closes via menus.close() directly, not through closePopovers; isAvailable() is engine-bound and the native dock only renders while it is true, with engine handoff calling menus.closeAll()). Regression test added for Space/Enter chip activation. The volume-overflow fix is CSS layout (no jsdom layout engine) and was validated in a real browser. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- 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/embedded-mpv-native.md | 50 +- docs/architecture/player-controls-contract.md | 5 +- .../embedded-mpv-dock-panel.component.html | 65 ++ .../embedded-mpv-dock-panel.component.scss | 166 ++++ .../embedded-mpv-dock-panel.component.spec.ts | 189 +++++ .../embedded-mpv-dock-panel.component.ts | 213 +++++ .../embedded-mpv-dock-panels.ts | 205 +++++ .../embedded-mpv-format.utils.ts | 10 - .../embedded-mpv-legacy-interactions.ts | 4 +- ...d-mpv-player.component.dock-panels.spec.ts | 324 ++++++++ .../embedded-mpv-player.component.html | 751 +++++++----------- .../embedded-mpv-player.component.scss | 139 +--- .../embedded-mpv-player.component.ts | 78 +- .../embedded-mpv-shortcuts.spec.ts | 17 + .../embedded-mpv-shortcuts.ts | 19 + .../embedded-mpv-ui-state.spec.ts | 16 + .../embedded-mpv-ui-state.ts | 19 +- 35 files changed, 1666 insertions(+), 622 deletions(-) create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.html create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.spec.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panels.ts create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index fb6f1f064..bf4ec546f 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "رجوع", "RETRY": "إعادة المحاولة", "STALLED": "يستغرق بدء هذا البث وقتًا أطول من المتوقع.", "PLAYBACK_FAILED": "فشل التشغيل عبر MPV المضمّن.", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3384a01bb..34d2bc61a 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "رجوع", "RETRY": "حاول مرة أخرى", "STALLED": "هاد البث كياخد وقت كثر من المتوقع باش يبدا.", "PLAYBACK_FAILED": "فشل التشغيل بـ MPV المدمج.", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index df3746201..307fe897c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Назад", "RETRY": "Паўтарыць", "STALLED": "Гэты паток запускаецца даўжэй, чым чакалася.", "PLAYBACK_FAILED": "Не ўдалося прайграць праз убудаваны MPV.", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 5d324b6ac..e530ef81d 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Zurück", "RETRY": "Erneut versuchen", "STALLED": "Der Start dieses Streams dauert länger als erwartet.", "PLAYBACK_FAILED": "Die Wiedergabe mit eingebettetem MPV ist fehlgeschlagen.", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 292507e06..24ad44f67 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Πίσω", "RETRY": "Επανάληψη", "STALLED": "Η έναρξη αυτής της ροής καθυστερεί περισσότερο από το αναμενόμενο.", "PLAYBACK_FAILED": "Η αναπαραγωγή με το ενσωματωμένο MPV απέτυχε.", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 2ec687719..d0e7651a2 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Back", "RETRY": "Retry", "STALLED": "This stream is taking longer than expected to start.", "PLAYBACK_FAILED": "Embedded MPV playback failed.", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index bee82556b..752453717 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Atrás", "RETRY": "Reintentar", "STALLED": "Este stream está tardando más de lo esperado en iniciarse.", "PLAYBACK_FAILED": "Falló la reproducción de MPV integrado.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index b020bc756..ae7d75e39 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Retour", "RETRY": "Réessayer", "STALLED": "Ce flux met plus de temps que prévu à démarrer.", "PLAYBACK_FAILED": "Échec de la lecture avec MPV intégré.", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 2207419fc..4b970e7e0 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Indietro", "RETRY": "Riprova", "STALLED": "L'avvio di questo stream sta richiedendo più tempo del previsto.", "PLAYBACK_FAILED": "Riproduzione con MPV integrato non riuscita.", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 24bed12b9..2d108d99c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "戻る", "RETRY": "再試行", "STALLED": "このストリームの開始に予想より時間がかかっています。", "PLAYBACK_FAILED": "組み込みMPVでの再生に失敗しました。", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 563e17b7c..25b7226bd 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "뒤로", "RETRY": "다시 시도", "STALLED": "이 스트림의 시작이 예상보다 오래 걸리고 있습니다.", "PLAYBACK_FAILED": "내장 MPV 재생에 실패했습니다.", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 4a7fa9131..fff5dfbee 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Terug", "RETRY": "Opnieuw proberen", "STALLED": "Deze stream doet er langer over dan verwacht om te starten.", "PLAYBACK_FAILED": "Afspelen via Embedded MPV is mislukt.", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d35d051ca..d853d98cb 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Wstecz", "RETRY": "Spróbuj ponownie", "STALLED": "Uruchamianie tego strumienia trwa dłużej niż zwykle.", "PLAYBACK_FAILED": "Odtwarzanie we wbudowanym MPV nie powiodło się.", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e1cfb1a61..c0cd4ade0 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Voltar", "RETRY": "Tentar novamente", "STALLED": "Este stream está demorando mais do que o esperado para iniciar.", "PLAYBACK_FAILED": "Falha na reprodução do MPV integrado.", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 5ca85485c..414e25772 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Назад", "RETRY": "Повторить", "STALLED": "Запуск этого потока занимает больше времени, чем ожидалось.", "PLAYBACK_FAILED": "Не удалось воспроизвести через встроенный MPV.", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index b0c0fd1c7..24c48b1c3 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "Geri", "RETRY": "Tekrar dene", "STALLED": "Bu yayının başlaması beklenenden uzun sürüyor.", "PLAYBACK_FAILED": "Gömülü MPV oynatması başarısız oldu.", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 957581e4f..db2db90ae 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "返回", "RETRY": "重试", "STALLED": "该流的启动时间超出预期。", "PLAYBACK_FAILED": "嵌入式 MPV 播放失败。", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 292ff3b8f..ed43744f8 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -453,6 +453,7 @@ }, "EMBEDDED_MPV": { "PLAYER": { + "BACK": "返回", "RETRY": "重試", "STALLED": "此串流的啟動時間比預期長。", "PLAYBACK_FAILED": "內嵌 MPV 播放失敗。", diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 458368467..6adbbaabf 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -246,7 +246,7 @@ service and the adapter): `attachEmbeddedMpvFrameView`/`detachEmbeddedMpvFrameView`. - Renderer: `EmbeddedMpvPlayerComponent` renders the canvas when `support.engine === 'frame-copy'` and skips the compositor workarounds — - no `HIDDEN_BOUNDS` when dialogs open, no popover bottom cutout; dialogs + no `HIDDEN_BOUNDS` when dialogs open; dialogs and the shared `app-player-controls` overlay stack above the canvas as ordinary DOM. The canvas fills the player root; the native dock's reserved controls height is not applied. Legacy embedded-MPV pointer/click, @@ -533,7 +533,7 @@ player component stays a view-oriented orchestrator and engine-specific controls host. The renderer files live under `libs/ui/playback/src/lib/embedded-mpv-player/`: -- `embedded-mpv-format.utils.ts` — pure helpers (`formatTime`, `audioTrackLabel`, `subtitleTrackLabel`, `speedLabel`, `aspectLabel`, `volumeIcon`, `volumeLabel`, `readStoredVolume`, `persistVolume`, `measureBounds`) and preset constants (`SPEED_PRESETS`, `ASPECT_PRESETS`, `HIDDEN_BOUNDS`, `MENU_OPEN_BOTTOM_CUTOUT_PX`). +- `embedded-mpv-format.utils.ts` — pure helpers (`formatTime`, `audioTrackLabel`, `subtitleTrackLabel`, `speedLabel`, `aspectLabel`, `volumeIcon`, `volumeLabel`, `readStoredVolume`, `persistVolume`, `measureBounds`) and preset constants (`SPEED_PRESETS`, `ASPECT_PRESETS`, `HIDDEN_BOUNDS`). - `embedded-mpv-controls.adapter.ts` — component-scoped `PlayerController` adapter for frame-copy. Maps session/support/playback signals to shared controls state and capabilities, delegates commands to @@ -554,7 +554,9 @@ controls host. The renderer files live under - `embedded-mpv-shortcuts.ts` — native-view-only `EmbeddedMpvShortcuts` class with `attach(handlers)` / `detach()`. Owns the legacy document keydown listener and routes through a callback interface; the component supplies - callbacks for Space/K, F, arrow keys, M, and Escape. + callbacks for Space/K, F, arrow keys, M, and Escape. The optional + `arrowKeysBlocked` handler suspends the seek/volume arrows while a dock + chip panel owns them for chip navigation. - `embedded-mpv-overlay-visibility.service.ts` — singleton service that exposes `overlayActive: signal`. Tracks `MatDialog.afterOpened`/ `afterAllClosed` for dialog-shaped overlays and falls back to a @@ -562,9 +564,21 @@ controls host. The renderer files live under backdrop-bearing CDK overlays. Native-view uses it to move the platform host off-screen; frame-copy uses it to gate shared playback shortcuts. - `embedded-mpv-ui-state.ts` — legacy native-view - `EmbeddedMpvMenuState` (single-open popover state machine) and + `EmbeddedMpvMenuState` (single-open menu state machine, incl. the + `dockPanelOpen` chip-panel signal that suspends arrow shortcuts) and `EmbeddedMpvFeedback` (transient keypress feedback). They are not the frame-copy shared-controls state. +- `embedded-mpv-dock-panels.ts` — native-view `EmbeddedMpvDockPanelState`: + builds the active horizontal chip-panel view model (audio, subtitle, speed, + aspect) from the menu state, routes chip selection back to the session + controller, and restores toggle-button focus after a panel closes. +- `embedded-mpv-dock-panel.component.ts` — standalone + `app-embedded-mpv-dock-panel` that morphs the dock row inside the + fixed-height controls strip: back button + title + horizontally scrollable + chip ribbon (`role="menu"` with `aria-orientation="horizontal"`, + `menuitemradio` chips, wheel-to-horizontal-scroll mapping, edge fades, + active-chip reveal/focus, roving arrow keys, RTL-aware). Keeping the panels + inside the strip is what lets menus open without any MPV bounds change. - `embedded-mpv-command-runner.ts` — transport/track/recording IPC delegation; contains addon-side throws; reconciles a returned snapshot only when the current canonical session id and returned snapshot id both match the captured command session id. - `embedded-mpv-session-factory.ts` — side-effect-free loading/error placeholder factories plus `waitForStartupPaint`. - `embedded-mpv-stalled-tracker.ts` — owns the 30-second loading timer and `stalled` signal. @@ -578,21 +592,37 @@ controls host. The renderer files live under ### Bounds compositing strategy -The following cutout strategy applies only to the native-view engine. Its video +The following strategy applies only to the native-view engine. Its video host paints outside the normal DOM stacking model, so any DOM region it covers cannot reliably receive pointer events and any CSS `z-index` competition is unwinnable. The component compensates with a single `boundsProvider(host)` -closure on the controller that returns one of three bound shapes, evaluated +closure on the controller that returns one of two bound shapes, evaluated each time the active bounds-sync runs: - **Modal overlay open** (any MatDialog, including the command palette) → `HIDDEN_BOUNDS`. The MPV video host moves off-screen so the dialog has the full window. -- **Control popover open** (any of the menu states above) → host bounds with `MENU_OPEN_BOTTOM_CUTOUT_PX` (300 px) removed from the bottom. The popover region becomes DOM-receiving while video keeps playing in the upper region. -- **Idle** → full host bounds. +- **Otherwise** → full host bounds. -The viewport DOM element also reserves `--embedded-mpv-controls-height` (64 px) at the bottom when controls are enabled, so the controls strip itself is always DOM and always reachable for hover-to-reveal even before the popover-cutout takes effect. +Control menus never influence bounds: all five (volume, audio, subtitle, +speed, aspect) render horizontally inside the fixed-height controls strip +below the video host. Volume expands as an inline horizontal slider next to +the mute button; the audio/subtitle/speed/aspect menus morph the dock row +into `app-embedded-mpv-dock-panel` — back button, panel title, and a +horizontally scrollable chip ribbon (vertical wheel mapped to horizontal +scroll, edge fades as continuation hints, auto-reveal and focus of the active +chip, roving arrow-key navigation, RTL-aware). Because the strip height never +changes, opening or closing a menu sends no new MPV bounds and the video never +re-letterboxes. The popover-era 300 px bottom cutout +(`MENU_OPEN_BOTTOM_CUTOUT_PX`) is gone; while a chip panel is open, the +global arrow-key shortcuts (seek/volume) are suspended so arrows walk the +chips instead. + +The viewport DOM element reserves `--embedded-mpv-controls-height` (64 px; +88 px under the narrow breakpoint) at the bottom when controls are enabled, so +the controls strip — including the in-dock panels — is always DOM and always +reachable for hover-to-reveal. For frame-copy, `boundsProvider` always returns the measured full host bounds: -there is no `HIDDEN_BOUNDS`, popover cutout, or reserved dock height. Dialogs +there is no `HIDDEN_BOUNDS` or reserved dock height. Dialogs and controls layer naturally over the canvas, while bounds sync still updates the helper's render size. diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index d6e1e52ea..dd565c991 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -506,8 +506,9 @@ back to the command's stale baseline. 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 native surface with `HIDDEN_BOUNDS`; control menus render as horizontal +panels inside the fixed-height dock strip, so they stay interactive without +any bounds change. The transparent BrowserWindow / `NSWindowBelow` tunnel-and-backdrop approach is not the shipped architecture. The shared-controls integration does not add diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.html new file mode 100644 index 000000000..1f8a300d0 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.html @@ -0,0 +1,65 @@ +
+ + + {{ panel().title }} + +
+ + + +
+
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss new file mode 100644 index 000000000..d4a021e3f --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss @@ -0,0 +1,166 @@ +:host { + display: block; + min-width: 0; +} + +.embedded-mpv-dock-panel { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + animation: embedded-mpv-dock-panel-in 160ms ease-out; +} + +@keyframes embedded-mpv-dock-panel-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.embedded-mpv-dock-panel__back { + flex: 0 0 auto; +} + +.embedded-mpv-dock-panel__title { + flex: 0 0 auto; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + color: color-mix(in srgb, var(--mat-sys-on-surface) 70%, transparent); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.embedded-mpv-dock-panel__ribbon-shell { + position: relative; + flex: 1 1 auto; + min-width: 0; +} + +.embedded-mpv-dock-panel__ribbon { + display: flex; + align-items: center; + gap: 8px; + padding: 2px; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; +} + +.embedded-mpv-dock-panel__ribbon::-webkit-scrollbar { + display: none; +} + +.embedded-mpv-dock-panel__chip { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + max-width: 220px; + height: 34px; + padding: 0 14px; + color: var(--mat-sys-on-surface); + background: transparent; + border: 1px solid var(--embedded-mpv-border); + border-radius: 999px; + cursor: pointer; + font: inherit; + font-size: 0.82rem; + white-space: nowrap; +} + +.embedded-mpv-dock-panel__chip:hover, +.embedded-mpv-dock-panel__chip:focus-visible { + background: color-mix(in srgb, var(--embedded-mpv-accent) 14%, transparent); +} + +.embedded-mpv-dock-panel__chip:focus-visible { + outline: 2px solid var(--embedded-mpv-accent); + outline-offset: 1px; +} + +.embedded-mpv-dock-panel__chip--selected { + background: color-mix(in srgb, var(--embedded-mpv-accent) 18%, transparent); + border-color: color-mix( + in srgb, + var(--embedded-mpv-accent) 55%, + transparent + ); +} + +.embedded-mpv-dock-panel__chip mat-icon { + flex: 0 0 auto; + width: 16px; + height: 16px; + font-size: 16px; + color: var(--embedded-mpv-accent); +} + +.embedded-mpv-dock-panel__chip-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.embedded-mpv-dock-panel__fade { + position: absolute; + top: 0; + bottom: 0; + width: 36px; + opacity: 0; + pointer-events: none; + transition: opacity 140ms ease-out; +} + +.embedded-mpv-dock-panel__fade--visible { + opacity: 1; +} + +.embedded-mpv-dock-panel__fade--start { + inset-inline-start: 0; + background: linear-gradient( + to right, + var(--embedded-mpv-glass), + transparent + ); +} + +.embedded-mpv-dock-panel__fade--end { + inset-inline-end: 0; + background: linear-gradient( + to left, + var(--embedded-mpv-glass), + transparent + ); +} + +:host-context([dir='rtl']) .embedded-mpv-dock-panel__fade--start { + background: linear-gradient( + to left, + var(--embedded-mpv-glass), + transparent + ); +} + +:host-context([dir='rtl']) .embedded-mpv-dock-panel__fade--end { + background: linear-gradient( + to right, + var(--embedded-mpv-glass), + transparent + ); +} + +@media (max-width: 720px) { + .embedded-mpv-dock-panel__title { + display: none; + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.spec.ts new file mode 100644 index 000000000..ba51e7313 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.spec.ts @@ -0,0 +1,189 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component'; +import { EmbeddedMpvDockPanelView } from './embedded-mpv-dock-panels'; + +const PANEL: EmbeddedMpvDockPanelView = { + kind: 'audio', + title: 'Audio tracks', + chips: Array.from({ length: 12 }, (_, index) => ({ + id: String(index + 1), + label: `Track ${index + 1} with a fairly long descriptive name`, + selected: index === 3, + })), +}; + +describe('EmbeddedMpvDockPanelComponent', () => { + let fixture: ComponentFixture; + + const chipButtons = (): HTMLButtonElement[] => + fixture.debugElement + .queryAll(By.css('.embedded-mpv-dock-panel__chip')) + .map((chip) => chip.nativeElement); + + const panelRoot = (): HTMLElement => + fixture.debugElement.query(By.css('.embedded-mpv-dock-panel')) + .nativeElement; + + const ribbon = (): HTMLElement => + fixture.debugElement.query(By.css('.embedded-mpv-dock-panel__ribbon')) + .nativeElement; + + const dispatchPanelKey = (key: string): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + (document.activeElement ?? panelRoot()).dispatchEvent(event); + return event; + }; + + const flushMicrotasks = () => new Promise(queueMicrotask); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EmbeddedMpvDockPanelComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(EmbeddedMpvDockPanelComponent); + fixture.componentRef.setInput('panel', PANEL); + fixture.componentRef.setInput('backLabel', 'Back'); + fixture.detectChanges(); + await flushMicrotasks(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('renders chips with tooltips, roving tabindex, and a selected marker', () => { + const chips = chipButtons(); + + expect(chips).toHaveLength(12); + expect(chips[3].getAttribute('aria-checked')).toBe('true'); + expect(chips[3].tabIndex).toBe(0); + expect(chips[0].tabIndex).toBe(-1); + expect(chips[5].getAttribute('title')).toBe(PANEL.chips[5].label); + expect(chips[3].querySelector('mat-icon')).not.toBeNull(); + expect(chips[0].querySelector('mat-icon')).toBeNull(); + }); + + it('focuses the selected chip when the panel opens', () => { + expect(document.activeElement).toBe(chipButtons()[3]); + }); + + it('walks chips with arrow keys, Home, and End', () => { + const chips = chipButtons(); + + dispatchPanelKey('ArrowRight'); + expect(document.activeElement).toBe(chips[4]); + + dispatchPanelKey('ArrowLeft'); + dispatchPanelKey('ArrowLeft'); + expect(document.activeElement).toBe(chips[2]); + + dispatchPanelKey('End'); + expect(document.activeElement).toBe(chips[11]); + dispatchPanelKey('ArrowRight'); + expect(document.activeElement).toBe(chips[11]); + + dispatchPanelKey('Home'); + expect(document.activeElement).toBe(chips[0]); + dispatchPanelKey('ArrowLeft'); + expect(document.activeElement).toBe(chips[0]); + }); + + it('claims arrow keys so document-level shortcuts never see them', () => { + const documentKeydown = jest.fn(); + document.addEventListener('keydown', documentKeydown); + + const horizontal = dispatchPanelKey('ArrowRight'); + const vertical = dispatchPanelKey('ArrowUp'); + const escape = dispatchPanelKey('Escape'); + + expect(horizontal.defaultPrevented).toBe(true); + expect(vertical.defaultPrevented).toBe(true); + expect(escape.defaultPrevented).toBe(false); + + const seenKeys = documentKeydown.mock.calls.map( + ([event]: [KeyboardEvent]) => event.key + ); + expect(seenKeys).toEqual(['Escape']); + + document.removeEventListener('keydown', documentKeydown); + }); + + it('lets Space/Enter activate a chip without leaking to global shortcuts', () => { + // The global shortcut handler's Space case calls preventDefault() + + // togglePaused(); preventing the keydown default would suppress the + // chip button's native activation. The panel must stop Space/Enter + // from reaching the document without cancelling their default action. + const documentKeydown = jest.fn(); + document.addEventListener('keydown', documentKeydown); + + const space = dispatchPanelKey(' '); + const enter = dispatchPanelKey('Enter'); + + // Not defaultPrevented → the focused chip button still activates. + expect(space.defaultPrevented).toBe(false); + expect(enter.defaultPrevented).toBe(false); + // stopPropagation → the global handler never sees them. + expect(documentKeydown).not.toHaveBeenCalled(); + + document.removeEventListener('keydown', documentKeydown); + }); + + it('maps vertical wheel deltas to horizontal ribbon scrolling', () => { + const ribbonEl = ribbon(); + ribbonEl.scrollLeft = 0; + + const wheel = new WheelEvent('wheel', { + deltaY: 120, + deltaX: 0, + cancelable: true, + }); + ribbonEl.dispatchEvent(wheel); + + expect(ribbonEl.scrollLeft).toBe(120); + expect(wheel.defaultPrevented).toBe(true); + + const horizontalSwipe = new WheelEvent('wheel', { + deltaY: 2, + deltaX: 40, + cancelable: true, + }); + ribbonEl.dispatchEvent(horizontalSwipe); + + expect(ribbonEl.scrollLeft).toBe(120); + expect(horizontalSwipe.defaultPrevented).toBe(false); + }); + + it('emits chipSelected and closed', () => { + const selected = jest.fn(); + const closed = jest.fn(); + fixture.componentInstance.chipSelected.subscribe(selected); + fixture.componentInstance.closed.subscribe(closed); + + chipButtons()[7].click(); + expect(selected).toHaveBeenCalledWith('8'); + + fixture.debugElement + .query(By.css('[data-test-id="embedded-mpv-dock-panel-back"]')) + .nativeElement.click(); + expect(closed).toHaveBeenCalledTimes(1); + }); + + it('keeps the first chip tabbable when no chip is selected', () => { + fixture.componentRef.setInput('panel', { + kind: 'aspect', + title: 'Aspect ratio', + chips: PANEL.chips.map((chip) => ({ ...chip, selected: false })), + }); + fixture.detectChanges(); + + const chips = chipButtons(); + expect(chips[0].tabIndex).toBe(0); + expect(chips[1].tabIndex).toBe(-1); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.ts new file mode 100644 index 000000000..712bb1e01 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.ts @@ -0,0 +1,213 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + effect, + inject, + input, + output, + signal, + untracked, + viewChild, + viewChildren, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import type { + EmbeddedMpvDockPanelKind, + EmbeddedMpvDockPanelView, +} from './embedded-mpv-dock-panels'; + +/** + * Horizontal chip panel that morphs the native-view embedded MPV dock row: + * back button + title on the inline-start side, then a scrollable chip + * ribbon. It lives inside the fixed-height controls strip, so opening it + * never changes the native MPV view bounds (no video re-letterboxing). + */ +@Component({ + selector: 'app-embedded-mpv-dock-panel', + templateUrl: './embedded-mpv-dock-panel.component.html', + styleUrl: './embedded-mpv-dock-panel.component.scss', + imports: [MatButtonModule, MatIconModule, MatTooltipModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmbeddedMpvDockPanelComponent implements OnDestroy { + readonly panel = input.required(); + readonly backLabel = input.required(); + + readonly chipSelected = output(); + readonly closed = output(); + + readonly canScrollStart = signal(false); + readonly canScrollEnd = signal(false); + + private readonly elementRef = inject>(ElementRef); + private readonly ribbon = + viewChild>('ribbon'); + private readonly chips = + viewChildren>('chip'); + + private lastRevealedKind: EmbeddedMpvDockPanelKind | null = null; + private destroyed = false; + + constructor() { + effect(() => { + const panel = this.panel(); + const kind = panel.kind; + const hasChips = panel.chips.length > 0; + untracked(() => { + const isNewPanel = kind !== this.lastRevealedKind; + this.lastRevealedKind = kind; + queueMicrotask(() => { + if (this.destroyed) { + return; + } + this.updateScrollState(); + if (isNewPanel && hasChips) { + this.focusAndRevealSelectedChip(); + } + }); + }); + }); + } + + ngOnDestroy(): void { + this.destroyed = true; + } + + chipTabIndex(selected: boolean, index: number): number { + if (selected) { + return 0; + } + const hasSelected = this.panel().chips.some((chip) => chip.selected); + return !hasSelected && index === 0 ? 0 : -1; + } + + onRibbonScroll(): void { + this.updateScrollState(); + } + + onRibbonWheel(event: WheelEvent): void { + // Vertical wheel drives horizontal ribbon scrolling; dominant + // horizontal deltas (trackpad swipes) keep native scrolling. + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) { + return; + } + const ribbonEl = this.ribbon()?.nativeElement; + if (!ribbonEl) { + return; + } + event.preventDefault(); + ribbonEl.scrollLeft += event.deltaY * (this.isRtl() ? -1 : 1); + this.updateScrollState(); + } + + onPanelKeydown(event: KeyboardEvent): void { + const key = event.key; + if (key === ' ' || key === 'Enter') { + // Let the focused chip button activate natively (Space/Enter → + // click → chipSelected), but stop the keydown before it reaches + // the global shortcut handler, whose Space case would otherwise + // preventDefault() the activation and toggle playback instead. + // No preventDefault here — that would re-suppress the click. + event.stopPropagation(); + return; + } + if (key === 'ArrowUp' || key === 'ArrowDown') { + // The panel owns the keyboard: never let the global volume + // shortcuts fire while a chip panel is open. + event.preventDefault(); + event.stopPropagation(); + return; + } + if ( + key !== 'ArrowLeft' && + key !== 'ArrowRight' && + key !== 'Home' && + key !== 'End' + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const chips = this.chips().map((chip) => chip.nativeElement); + if (!chips.length) { + return; + } + chips[this.nextChipIndex(key, chips)]?.focus(); + } + + private nextChipIndex(key: string, chips: HTMLButtonElement[]): number { + if (key === 'Home') { + return 0; + } + if (key === 'End') { + return chips.length - 1; + } + const forward = (key === 'ArrowRight') !== this.isRtl(); + const currentIndex = chips.indexOf( + document.activeElement as HTMLButtonElement + ); + if (currentIndex === -1) { + return forward ? 0 : chips.length - 1; + } + return Math.max( + 0, + Math.min(chips.length - 1, currentIndex + (forward ? 1 : -1)) + ); + } + + private focusAndRevealSelectedChip(): void { + const chips = this.chips().map((chip) => chip.nativeElement); + const selected = + chips.find((chip) => chip.getAttribute('aria-checked') === 'true') ?? + chips[0]; + if (!selected) { + return; + } + try { + selected.focus({ preventScroll: true }); + } catch { + selected.focus(); + } + this.centerChipInRibbon(selected); + } + + private centerChipInRibbon(chip: HTMLElement): void { + const ribbonEl = this.ribbon()?.nativeElement; + if (!ribbonEl) { + return; + } + const ribbonRect = ribbonEl.getBoundingClientRect(); + const chipRect = chip.getBoundingClientRect(); + // Physical-axis math keeps this correct in RTL; the browser clamps + // scrollLeft to the valid range on both directions. + ribbonEl.scrollLeft += + chipRect.left + + chipRect.width / 2 - + (ribbonRect.left + ribbonRect.width / 2); + this.updateScrollState(); + } + + private updateScrollState(): void { + const ribbonEl = this.ribbon()?.nativeElement; + if (!ribbonEl) { + return; + } + const maxScroll = Math.max( + 0, + ribbonEl.scrollWidth - ribbonEl.clientWidth + ); + const offset = Math.abs(ribbonEl.scrollLeft); + this.canScrollStart.set(maxScroll > 1 && offset > 1); + this.canScrollEnd.set(maxScroll > 1 && offset < maxScroll - 1); + } + + private isRtl(): boolean { + return ( + getComputedStyle(this.elementRef.nativeElement).direction === 'rtl' + ); + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panels.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panels.ts new file mode 100644 index 000000000..e12e0c5ae --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panels.ts @@ -0,0 +1,205 @@ +import { Signal, computed } from '@angular/core'; +import { EmbeddedMpvAudioTrack } from '@iptvnator/shared/interfaces'; +import { ASPECT_PRESETS, SPEED_PRESETS } from './embedded-mpv-format.utils'; +import type { EmbeddedMpvMenuState } from './embedded-mpv-ui-state'; + +export type EmbeddedMpvDockPanelKind = + | 'audio' + | 'subtitle' + | 'speed' + | 'aspect'; + +export interface EmbeddedMpvDockChip { + readonly id: string; + readonly label: string; + readonly selected: boolean; +} + +export interface EmbeddedMpvDockPanelView { + readonly kind: EmbeddedMpvDockPanelKind; + readonly title: string; + readonly chips: readonly EmbeddedMpvDockChip[]; +} + +/** Chip id representing the "subtitles off" (-1) selection. */ +export const SUBTITLES_OFF_CHIP_ID = 'off'; + +export interface EmbeddedMpvDockPanelDeps { + readonly menus: EmbeddedMpvMenuState; + readonly audioTracks: Signal; + readonly subtitleTracks: Signal; + readonly selectedSubtitleTrackId: Signal; + readonly playbackSpeed: Signal; + readonly aspectOverride: Signal; + readonly translateLabel: (key: string) => string; + readonly audioTrackLabel: ( + track: EmbeddedMpvAudioTrack, + index: number + ) => string; + readonly subtitleTrackLabel: ( + track: EmbeddedMpvAudioTrack, + index: number + ) => string; + readonly aspectLabel: (aspect: string) => string; + readonly selectAudioTrack: (trackId: number) => void; + readonly selectSubtitleTrack: (trackId: number) => void; + readonly selectSpeed: (speed: number) => void; + readonly selectAspect: (aspect: string) => void; + readonly closePanels: () => void; + readonly playerRoot: () => HTMLElement | null; + readonly revealControls: () => void; +} + +/** + * View model for the horizontal in-dock chip panels of the native-view + * embedded MPV dock (audio, subtitle, speed, aspect). The panels morph the + * controls row inside its fixed-height strip, so opening one never changes + * the native MPV view bounds — unlike the removed popover-era bottom cutout. + */ +export class EmbeddedMpvDockPanelState { + readonly active: Signal; + + private openerKind: EmbeddedMpvDockPanelKind | null = null; + + constructor(private readonly deps: EmbeddedMpvDockPanelDeps) { + this.active = computed(() => this.buildActivePanel()); + } + + toggle(kind: EmbeddedMpvDockPanelKind): void { + // Always remember the button that opened a panel so focus returns to + // it when the panel closes. restoreOpenerFocus() only acts when focus + // has fallen to document.body, so re-toggling a panel with focus still + // on its button never steals focus. + this.openerKind = kind; + this.deps.menus.toggle(kind); + this.deps.revealControls(); + } + + /** + * Call whenever `menus.dockPanelOpen()` changes. When a panel closes it + * takes keyboard focus down with it (the dock row re-renders, so the + * pre-open button instance no longer exists); focus is handed to the + * freshly rendered toggle button of the menu that was open. + */ + handlePanelOpenChange(panelOpen: boolean): void { + if (panelOpen) { + return; + } + const kind = this.openerKind; + this.openerKind = null; + if (!kind) { + return; + } + queueMicrotask(() => this.restoreOpenerFocus(kind)); + } + + select(chipId: string): void { + const panel = this.active(); + if (!panel) { + return; + } + switch (panel.kind) { + case 'audio': + this.deps.selectAudioTrack(Number(chipId)); + return; + case 'subtitle': + this.deps.selectSubtitleTrack( + chipId === SUBTITLES_OFF_CHIP_ID ? -1 : Number(chipId) + ); + return; + case 'speed': + this.deps.selectSpeed(Number(chipId)); + return; + case 'aspect': + this.deps.selectAspect(chipId); + return; + } + } + + close(): void { + this.deps.closePanels(); + } + + private restoreOpenerFocus(kind: EmbeddedMpvDockPanelKind): void { + if ( + document.activeElement !== document.body && + document.activeElement !== null + ) { + return; + } + this.deps + .playerRoot() + ?.querySelector( + `[data-embedded-mpv-menu-button="${kind}"]` + ) + ?.focus(); + } + + private buildActivePanel(): EmbeddedMpvDockPanelView | null { + const { menus } = this.deps; + if (menus.audioOpen()) { + return { + kind: 'audio', + title: this.deps.translateLabel( + 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' + ), + chips: this.deps + .audioTracks() + .map((track, index) => ({ + id: String(track.id), + label: this.deps.audioTrackLabel(track, index), + selected: track.selected === true, + })), + }; + } + if (menus.subtitleOpen()) { + return { + kind: 'subtitle', + title: this.deps.translateLabel('EMBEDDED_MPV.PLAYER.SUBTITLES'), + chips: [ + { + id: SUBTITLES_OFF_CHIP_ID, + label: this.deps.translateLabel( + 'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' + ), + selected: this.deps.selectedSubtitleTrackId() === null, + }, + ...this.deps.subtitleTracks().map((track, index) => ({ + id: String(track.id), + label: this.deps.subtitleTrackLabel(track, index), + selected: track.selected === true, + })), + ], + }; + } + if (menus.speedOpen()) { + const speed = this.deps.playbackSpeed(); + return { + kind: 'speed', + title: this.deps.translateLabel( + 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' + ), + chips: SPEED_PRESETS.map((preset) => ({ + id: String(preset.value), + label: preset.label, + selected: preset.value === speed, + })), + }; + } + if (menus.aspectOpen()) { + const aspect = this.deps.aspectOverride(); + return { + kind: 'aspect', + title: this.deps.translateLabel( + 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' + ), + chips: ASPECT_PRESETS.map((preset) => ({ + id: preset.value, + label: this.deps.aspectLabel(preset.value), + selected: preset.value === aspect, + })), + }; + } + return null; + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts index 2e8635b6d..2fb657c2a 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts @@ -10,16 +10,6 @@ export const HIDDEN_BOUNDS: EmbeddedMpvBounds = Object.freeze({ height: 1, }) as EmbeddedMpvBounds; -/** - * Vertical pixels to subtract from the MPV view's height when a control - * popover (volume, audio, subtitle, speed, aspect) is open above the - * controls strip. The native NSView paints over the WebContents, so we - * shrink it from the bottom to expose the popover region in DOM. Sized to - * cover the tallest popover (audio/subtitle list capped at ~240 px plus - * title + padding); video keeps playing in the upper region. - */ -export const MENU_OPEN_BOTTOM_CUTOUT_PX = 300; - export const SPEED_PRESETS: ReadonlyArray<{ value: number; label: string }> = [ { value: 0.5, label: '0.5×' }, { value: 0.75, label: '0.75×' }, diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts index 343da45bf..20b3b3bb0 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-legacy-interactions.ts @@ -17,7 +17,6 @@ export interface EmbeddedMpvLegacyInteractionsDeps { readonly statusLabel: () => string; readonly togglePaused: () => void | Promise; readonly toggleFullscreen: () => void | Promise; - readonly triggerBoundsSync: () => void; } export class EmbeddedMpvLegacyInteractions { @@ -165,8 +164,9 @@ export class EmbeddedMpvLegacyInteractions { if (!this.deps.isAvailable() || !this.deps.menus.anyOpen()) { return; } + // Menus live inside the fixed-height dock strip, so closing them + // needs no bounds resync — the native MPV view never moved. this.deps.menus.closeAll(); - this.deps.triggerBoundsSync(); this.scheduleControlsHide(); } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts new file mode 100644 index 000000000..b7d38ed10 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts @@ -0,0 +1,324 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import { + EmbeddedMpvSession, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { HIDDEN_BOUNDS } from './embedded-mpv-format.utils'; +import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service'; +import { EmbeddedMpvPlayerComponent } from './embedded-mpv-player.component'; +import { + EmbeddedMpvBoundsProvider, + EmbeddedMpvSessionController, +} from './embedded-mpv-session-controller'; + +@Component({ + imports: [EmbeddedMpvPlayerComponent], + template: ``, +}) +class DockPanelsHostComponent { + playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.test/movie/42.mp4', + title: 'Movie', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 42, + contentType: 'movie', + }, + }; +} + +const HOST_RECT = { left: 4, top: 8, width: 1280, height: 720 }; +const FULL_BOUNDS = { x: 4, y: 8, width: 1280, height: 720 }; +const HOST_STUB = { + getBoundingClientRect: () => HOST_RECT, +} as unknown as HTMLElement; + +describe('EmbeddedMpvPlayerComponent dock panels', () => { + let fixture: ComponentFixture; + let player: EmbeddedMpvPlayerComponent; + let controller: EmbeddedMpvSessionController; + let boundsProviderSpy: jest.SpyInstance; + const overlayActive = signal(false); + + const boundsProvider = (): EmbeddedMpvBoundsProvider => + boundsProviderSpy.mock.calls[0][0]; + + const query = (selector: string) => + fixture.debugElement.query(By.css(selector)); + const queryAll = (selector: string) => + fixture.debugElement.queryAll(By.css(selector)); + + const configureReadyController = () => { + controller.support.set({ + supported: true, + platform: 'darwin', + engine: 'native', + capabilities: { + subtitles: true, + playbackSpeed: true, + aspectOverride: true, + screenshot: false, + recording: false, + }, + }); + controller.session.set({ + id: 'session-1', + title: 'Movie', + streamUrl: 'https://example.test/movie/42.mp4', + status: 'playing', + positionSeconds: 30, + durationSeconds: 120, + volume: 1, + audioTracks: [ + { + id: 1, + language: 'eng', + selected: true, + defaultTrack: true, + }, + { id: 2, language: 'deu', selected: false }, + ], + selectedAudioTrackId: 1, + subtitleTracks: [ + { id: 21, language: 'eng', selected: false }, + { id: 22, language: 'deu', selected: false }, + ], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + recording: { active: false }, + startedAt: '2026-07-19T12:00:00Z', + updatedAt: '2026-07-19T12:00:00Z', + } satisfies EmbeddedMpvSession); + fixture.detectChanges(); + }; + + beforeEach(async () => { + overlayActive.set(false); + boundsProviderSpy = jest.spyOn( + EmbeddedMpvSessionController.prototype, + 'setBoundsProvider' + ); + + await TestBed.configureTestingModule({ + imports: [DockPanelsHostComponent, TranslateModule.forRoot()], + providers: [ + { + provide: EmbeddedMpvOverlayVisibilityService, + useValue: { overlayActive }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DockPanelsHostComponent); + fixture.detectChanges(); + const playerDebugElement = fixture.debugElement.query( + By.directive(EmbeddedMpvPlayerComponent) + ); + player = playerDebugElement.componentInstance; + controller = playerDebugElement.injector.get( + EmbeddedMpvSessionController + ); + configureReadyController(); + }); + + afterEach(() => { + fixture.destroy(); + boundsProviderSpy.mockRestore(); + }); + + it('keeps full host bounds while every control menu is open', () => { + const provider = boundsProvider(); + + expect(provider(HOST_STUB)).toEqual(FULL_BOUNDS); + + for (const menu of [ + 'volume', + 'audio', + 'subtitle', + 'speed', + 'aspect', + ] as const) { + player.menus.open(menu); + // Core regression: menus render inside the fixed dock strip, so + // the native MPV view must never shrink (no bottom cutout). + expect(provider(HOST_STUB)).toEqual(FULL_BOUNDS); + } + }); + + it('still hides the native view while a modal overlay is active', () => { + overlayActive.set(true); + expect(boundsProvider()(HOST_STUB)).toEqual(HIDDEN_BOUNDS); + + overlayActive.set(false); + expect(boundsProvider()(HOST_STUB)).toEqual(FULL_BOUNDS); + }); + + it('morphs the dock row into a horizontal audio panel with menu roles', () => { + query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click(); + fixture.detectChanges(); + + expect(query('.embedded-mpv-player__transport')).toBeNull(); + expect(query('app-embedded-mpv-dock-panel')).not.toBeNull(); + + const ribbon = query('.embedded-mpv-dock-panel__ribbon'); + expect(ribbon.attributes['role']).toBe('menu'); + expect(ribbon.attributes['aria-orientation']).toBe('horizontal'); + + const chips = queryAll('.embedded-mpv-dock-panel__chip'); + expect(chips).toHaveLength(2); + expect(chips[0].attributes['role']).toBe('menuitemradio'); + expect(chips[0].nativeElement.getAttribute('aria-checked')).toBe( + 'true' + ); + expect(chips[0].nativeElement.tabIndex).toBe(0); + expect(chips[1].nativeElement.getAttribute('aria-checked')).toBe( + 'false' + ); + expect(chips[1].nativeElement.tabIndex).toBe(-1); + expect(chips[1].nativeElement.getAttribute('title')).toContain('deu'); + }); + + it('selects an audio chip, closes the panel, and restores the row', async () => { + const setAudioTrack = jest + .spyOn(controller, 'setAudioTrack') + .mockResolvedValue(undefined); + + query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click(); + fixture.detectChanges(); + queryAll('.embedded-mpv-dock-panel__chip')[1].nativeElement.click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(setAudioTrack).toHaveBeenCalledWith(2); + expect(player.menus.audioOpen()).toBe(false); + expect(query('app-embedded-mpv-dock-panel')).toBeNull(); + expect(query('.embedded-mpv-player__transport')).not.toBeNull(); + }); + + it('renders the subtitles-off chip first and maps it to track -1', async () => { + const setSubtitleTrack = jest + .spyOn(controller, 'setSubtitleTrack') + .mockResolvedValue(undefined); + + query( + '[data-embedded-mpv-menu-button="subtitle"]' + ).nativeElement.click(); + fixture.detectChanges(); + + const chips = queryAll('.embedded-mpv-dock-panel__chip'); + expect(chips).toHaveLength(3); + expect(chips[0].nativeElement.getAttribute('aria-checked')).toBe( + 'true' + ); + + chips[0].nativeElement.click(); + await fixture.whenStable(); + expect(setSubtitleTrack).toHaveBeenCalledWith(-1); + + query( + '[data-embedded-mpv-menu-button="subtitle"]' + ).nativeElement.click(); + fixture.detectChanges(); + queryAll('.embedded-mpv-dock-panel__chip')[2].nativeElement.click(); + await fixture.whenStable(); + expect(setSubtitleTrack).toHaveBeenCalledWith(22); + }); + + it('selects speed and aspect presets from horizontal chip rows', async () => { + const setSpeed = jest + .spyOn(controller, 'setSpeed') + .mockResolvedValue(undefined); + const setAspect = jest + .spyOn(controller, 'setAspect') + .mockResolvedValue(undefined); + + query('[data-embedded-mpv-menu-button="speed"]').nativeElement.click(); + fixture.detectChanges(); + expect(queryAll('.embedded-mpv-dock-panel__chip')).toHaveLength(6); + queryAll('.embedded-mpv-dock-panel__chip')[4].nativeElement.click(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(setSpeed).toHaveBeenCalledWith(1.5); + + query('[data-embedded-mpv-menu-button="aspect"]').nativeElement.click(); + fixture.detectChanges(); + expect(queryAll('.embedded-mpv-dock-panel__chip')).toHaveLength(5); + queryAll('.embedded-mpv-dock-panel__chip')[1].nativeElement.click(); + await fixture.whenStable(); + expect(setAspect).toHaveBeenCalledWith('16:9'); + }); + + it('expands volume inline without morphing the row', () => { + player.menus.open('volume'); + fixture.detectChanges(); + + expect(query('.embedded-mpv-player__volume-inline')).not.toBeNull(); + expect(query('.embedded-mpv-player__transport')).not.toBeNull(); + expect(query('app-embedded-mpv-dock-panel')).toBeNull(); + + const slider = query('.embedded-mpv-player__slider--volume') + .nativeElement as HTMLInputElement; + slider.value = '0.4'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + + expect(player.volume()).toBe(0.4); + }); + + it('closes an open panel with Escape', () => { + query('[data-embedded-mpv-menu-button="speed"]').nativeElement.click(); + fixture.detectChanges(); + expect(player.menus.speedOpen()).toBe(true); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); + fixture.detectChanges(); + + expect(player.menus.anyOpen()).toBe(false); + expect(query('.embedded-mpv-player__transport')).not.toBeNull(); + }); + + it('blocks seek and volume arrow shortcuts while a chip panel is open', () => { + const seekBy = jest + .spyOn(controller, 'seekBy') + .mockResolvedValue(true); + const volumeBefore = player.volume(); + + query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click(); + fixture.detectChanges(); + + for (const key of [ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + ]) { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }) + ); + } + + expect(seekBy).not.toHaveBeenCalled(); + expect(player.volume()).toBe(volumeBefore); + + player.menus.closeAll(); + fixture.detectChanges(); + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + }) + ); + expect(seekBy).toHaveBeenCalledWith(5); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index 6377edab6..492ddc6fd 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -111,229 +111,249 @@ [class.embedded-mpv-player__controls--visible]=" controlsAreVisible() " + [class.embedded-mpv-player__controls--panel]=" + dockPanels.active() !== null + " > -
- - - @if (showSeriesNavigation()) { - - - - } - - - - -
- -
- -
- {{ formatTime(timelineValue()) }} - @if (canSeek()) { - {{ - formatTime(session()?.durationSeconds) - }} - } @else if (isLivePlayback()) { - - - LIVE - - } @else { - --:-- - } -
- @if (recordingStatusText(); as recordingStatus) { -
+
- } -
- -
-
- - @if (menus.volumeOpen()) { - - } -
- @if (hasAudioTracks()) { -
+ @if (showSeriesNavigation()) { + + + } + + + + +
+ +
+ +
+ {{ formatTime(timelineValue()) }} + @if (canSeek()) { + {{ + formatTime(session()?.durationSeconds) + }} + } @else if (isLivePlayback()) { + + + LIVE + + } @else { + --:-- + } +
+ @if (recordingStatusText(); as recordingStatus) { +
+ {{ + isRecording() + ? 'fiber_manual_record' + : 'check_circle' + }} + {{ recordingStatus }} +
+ } +
+ +
+
+ + @if (menus.volumeOpen()) { +
+ + {{ volumePercent() }} +
+ } +
+ + @if (hasAudioTracks()) { + - } -
-
- } - - } + } - @if (capabilities().subtitles && hasSubtitleTracks()) { -
+ @if (capabilities().subtitles && hasSubtitleTracks()) { - @for ( - track of subtitleTracks(); - track track.id; - let index = $index - ) { - - } -
- - } - - } + } - @if (capabilities().playbackSpeed) { -
+ @if (capabilities().playbackSpeed) { - } -
- - } - - } + } - @if (capabilities().aspectOverride) { -
+ @if (capabilities().aspectOverride) { - } -
- - } - - } + } + + @if (canRecord()) { + + } - @if (canRecord()) { - } - - - + + } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss index 23ffa5560..3c4151932 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss @@ -294,17 +294,29 @@ display: flex; align-items: center; gap: 8px; + // Let the actions cell shrink below its content so the inline volume + // (a scroll container) clips its own slider instead of pushing the + // fullscreen button past the overflow:hidden player edge on narrow + // sidebar-constrained widths (viewport wider than the 720px breakpoint). + min-width: 0; } -.embedded-mpv-player__popover-anchor { - position: relative; +.embedded-mpv-player__volume-group { display: flex; + align-items: center; + min-width: 0; } .embedded-mpv-player__control-button { position: relative; } +/* A dock panel morphs the whole controls row into a single full-width + cell; the strip keeps its fixed height so MPV bounds stay untouched. */ +.embedded-mpv-player__controls--panel { + grid-template-columns: minmax(0, 1fr); +} + .embedded-mpv-player__record-button--active { color: var(--mat-sys-error) !important; } @@ -398,115 +410,40 @@ box-shadow: none; } -.embedded-mpv-player__control-panel { - min-width: 0; - color: var(--mat-sys-on-surface); - background: var(--embedded-mpv-glass); - border: 1px solid var(--embedded-mpv-border); - border-radius: 14px; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); - backdrop-filter: blur(18px); +/* Volume expands inline next to the mute icon, inside the actions row — + never above the strip, so it needs no compositor workarounds. */ +.embedded-mpv-player__volume-inline { + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; + animation: embedded-mpv-volume-in 160ms ease-out; } -.embedded-mpv-player__volume-popover { - position: absolute; - bottom: calc(100% + 6px); - right: 0; - display: grid; - grid-template-columns: minmax(160px, 200px); - gap: 4px; - padding: 12px 14px 10px; - z-index: 3; +@keyframes embedded-mpv-volume-in { + from { + max-width: 0; + opacity: 0; + } + to { + max-width: 200px; + opacity: 1; + } } -.embedded-mpv-player__volume-popover::before { - /* Hover-bridge so the cursor can move from the button to the popover - without crossing a dead zone. */ - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: -8px; - height: 8px; -} - -.embedded-mpv-player__volume-popover .embedded-mpv-player__slider--volume { - width: 100%; +.embedded-mpv-player__volume-inline .embedded-mpv-player__slider--volume { + width: 132px; } .embedded-mpv-player__volume-value { color: color-mix(in srgb, var(--mat-sys-on-surface) 80%, transparent); font-size: 0.74rem; font-variant-numeric: tabular-nums; - text-align: right; + min-width: 34px; + text-align: end; white-space: nowrap; } -.embedded-mpv-player__audio-popover { - position: absolute; - bottom: calc(100% + 6px); - right: 0; - min-width: 220px; - max-width: min(420px, 80vw); - padding: 10px 12px; - z-index: 3; -} - -.embedded-mpv-player__menu-title { - padding: 0 2px 6px; - color: color-mix(in srgb, var(--mat-sys-on-surface) 70%, transparent); - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - white-space: nowrap; -} - -.embedded-mpv-player__audio-track-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 240px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) - transparent; -} - -.embedded-mpv-player__audio-track { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 10px; - color: var(--mat-sys-on-surface); - background: transparent; - border: 0; - border-radius: 10px; - cursor: pointer; - font: inherit; - text-align: left; -} - -.embedded-mpv-player__audio-track span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.embedded-mpv-player__audio-track:hover, -.embedded-mpv-player__audio-track:focus-visible, -.embedded-mpv-player__audio-track--selected { - background: color-mix(in srgb, var(--embedded-mpv-accent) 16%, transparent); -} - -.embedded-mpv-player__audio-track mat-icon { - color: var(--embedded-mpv-accent); - font-size: 18px; - width: 18px; - height: 18px; -} - .embedded-mpv-player :is(button[mat-icon-button]) { color: var(--mat-sys-on-surface); } @@ -531,9 +468,7 @@ min-width: 0; } - .embedded-mpv-player__volume-popover, - .embedded-mpv-player__audio-popover { - right: 0; - left: auto; + .embedded-mpv-player__volume-inline .embedded-mpv-player__slider--volume { + width: 96px; } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index a5a388c24..74b1a003a 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -26,6 +26,8 @@ import { import { PlayerControlsComponent } from '../player-controls/player-controls.component'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter'; +import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component'; +import { EmbeddedMpvDockPanelState } from './embedded-mpv-dock-panels'; import { EmbeddedMpvLegacyInteractions } from './embedded-mpv-legacy-interactions'; import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service'; import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; @@ -37,7 +39,6 @@ import { import { ASPECT_PRESETS, HIDDEN_BOUNDS, - MENU_OPEN_BOTTOM_CUTOUT_PX, SPEED_PRESETS, aspectLabel, audioTrackLabel, @@ -57,6 +58,7 @@ const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000; templateUrl: './embedded-mpv-player.component.html', styleUrl: './embedded-mpv-player.component.scss', imports: [ + EmbeddedMpvDockPanelComponent, MatButtonModule, MatIconModule, MatProgressSpinnerModule, @@ -108,6 +110,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { private readonly shortcuts = new EmbeddedMpvShortcuts(); readonly menus = new EmbeddedMpvMenuState(); readonly feedback = new EmbeddedMpvFeedback(); + readonly dockPanels: EmbeddedMpvDockPanelState; readonly viewport = viewChild>('viewport'); readonly playerRoot = viewChild>('playerRoot'); @@ -230,6 +233,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { percent: Math.round(this.volume() * 100), }); }); + readonly volumePercent = computed( + () => `${Math.round(this.volume() * 100)}%` + ); + readonly dockPanelBackLabel = computed(() => { + this.translationsTick(); + return this.translate.instant('EMBEDDED_MPV.PLAYER.BACK'); + }); /** * Non-null while the user drags the timeline: the slider and time label * preview this value locally and the single seek IPC call is deferred to @@ -328,10 +338,34 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { statusLabel: this.statusLabel, togglePaused: () => this.togglePaused(), toggleFullscreen: () => this.toggleFullscreen(), - triggerBoundsSync: () => this.controller.triggerBoundsSync(), }); this.legacyInteractions.attach(); + this.dockPanels = new EmbeddedMpvDockPanelState({ + menus: this.menus, + audioTracks: this.audioTracks, + subtitleTracks: this.subtitleTracks, + selectedSubtitleTrackId: this.selectedSubtitleTrackId, + playbackSpeed: this.playbackSpeed, + aspectOverride: this.aspectOverride, + translateLabel: (key) => { + this.translationsTick(); + return this.translate.instant(key); + }, + audioTrackLabel: (track, index) => this.trackLabel(track, index), + subtitleTrackLabel: (track, index) => + this.subtitleLabel(track, index), + aspectLabel: (aspect) => this.aspectLabel(aspect), + selectAudioTrack: (trackId) => void this.selectAudioTrack(trackId), + selectSubtitleTrack: (trackId) => + void this.selectSubtitleTrack(trackId), + selectSpeed: (speed) => void this.selectSpeed(speed), + selectAspect: (aspect) => void this.selectAspect(aspect), + closePanels: () => this.legacyInteractions.closePopovers(), + playerRoot: () => this.playerRoot()?.nativeElement ?? null, + revealControls: () => this.legacyInteractions.revealControls(), + }); + this.sharedControls.configure({ playback: this.playback, seriesNavigation: this.seriesNavigation, @@ -347,32 +381,26 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.controller.setBoundsProvider((host) => { // The frame-copy engine paints into an ordinary DOM canvas: - // dialogs and popovers stack above it natively, so the - // hide-offscreen and popover-cutout compositor workarounds - // must not shrink its render size. + // dialogs and overlays stack above it natively, so the + // hide-offscreen compositor workaround must not shrink its + // render size. if (this.isFrameCopyEngine()) { return measureBounds(host); } if (this.overlayVisibility.overlayActive()) { return HIDDEN_BOUNDS; } - const rect = measureBounds(host); - if (this.menus.anyOpen()) { - return { - ...rect, - height: Math.max( - 1, - rect.height - MENU_OPEN_BOTTOM_CUTOUT_PX - ), - }; - } - return rect; + // Control menus render as horizontal panels inside the + // fixed-height dock strip below the video host, so open menus + // never require shrinking the native MPV view. + return measureBounds(host); }); this.shortcuts.attach({ isAvailable: () => this.legacyInteractions.isAvailable() && !this.overlayVisibility.overlayActive(), + arrowKeysBlocked: () => this.menus.dockPanelOpen(), onEscape: () => this.legacyInteractions.closePopovers(), togglePaused: () => void this.togglePaused(), toggleFullscreen: () => void this.toggleFullscreen(), @@ -411,10 +439,14 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { effect(() => { this.overlayVisibility.overlayActive(); - this.menus.anyOpen(); this.controller.triggerBoundsSync(); }); + effect(() => { + const panelOpen = this.menus.dockPanelOpen(); + untracked(() => this.dockPanels.handlePanelOpenChange(panelOpen)); + }); + effect(() => { const session = this.session(); if (!session) { @@ -601,20 +633,16 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { } toggleAudioMenu(): void { - this.menus.toggle('audio'); - this.legacyInteractions.revealControls(); + this.dockPanels.toggle('audio'); } toggleSubtitleMenu(): void { - this.menus.toggle('subtitle'); - this.legacyInteractions.revealControls(); + this.dockPanels.toggle('subtitle'); } toggleSpeedMenu(): void { - this.menus.toggle('speed'); - this.legacyInteractions.revealControls(); + this.dockPanels.toggle('speed'); } toggleAspectMenu(): void { - this.menus.toggle('aspect'); - this.legacyInteractions.revealControls(); + this.dockPanels.toggle('aspect'); } async selectAudioTrack(trackId: number): Promise { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts index c9140dd44..5c39d60d7 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.spec.ts @@ -48,6 +48,23 @@ describe('EmbeddedMpvShortcuts', () => { expect(handlers.toggleMute).toHaveBeenCalledTimes(1); }); + it('suspends arrow shortcuts while a dock chip panel owns the keyboard', () => { + shortcuts.detach(); + shortcuts.attach({ ...handlers, arrowKeysBlocked: () => true }); + + expect(dispatchKey('ArrowLeft')).toBe(false); + expect(dispatchKey('ArrowRight')).toBe(false); + expect(dispatchKey('ArrowUp')).toBe(false); + expect(dispatchKey('ArrowDown')).toBe(false); + expect(dispatchKey(' ')).toBe(true); + expect(dispatchKey('m')).toBe(true); + + expect(handlers.seekBy).not.toHaveBeenCalled(); + expect(handlers.adjustVolume).not.toHaveBeenCalled(); + expect(handlers.togglePaused).toHaveBeenCalledTimes(1); + expect(handlers.toggleMute).toHaveBeenCalledTimes(1); + }); + it('always allows escape to close popovers even when playback is unavailable', () => { handlers.isAvailable.mockReturnValue(false); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts index 785cb6131..80f01e253 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-shortcuts.ts @@ -1,5 +1,10 @@ export interface EmbeddedMpvShortcutHandlers { isAvailable: () => boolean; + /** + * While true, arrow keys stop seeking/adjusting volume — an open dock + * chip panel owns them for chip navigation instead. + */ + arrowKeysBlocked?: () => boolean; onEscape: () => void; togglePaused: () => void; toggleFullscreen: () => void; @@ -41,6 +46,8 @@ export class EmbeddedMpvShortcuts { return; } + const arrowsBlocked = handlers.arrowKeysBlocked?.() === true; + switch (event.key) { case ' ': case 'k': @@ -54,18 +61,30 @@ export class EmbeddedMpvShortcuts { handlers.toggleFullscreen(); return; case 'ArrowLeft': + if (arrowsBlocked) { + return; + } event.preventDefault(); handlers.seekBy(-5); return; case 'ArrowRight': + if (arrowsBlocked) { + return; + } event.preventDefault(); handlers.seekBy(5); return; case 'ArrowUp': + if (arrowsBlocked) { + return; + } event.preventDefault(); handlers.adjustVolume(0.05); return; case 'ArrowDown': + if (arrowsBlocked) { + return; + } event.preventDefault(); handlers.adjustVolume(-0.05); return; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts index f4a27a224..ed1a57041 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.spec.ts @@ -19,6 +19,22 @@ describe('EmbeddedMpvMenuState', () => { expect(menus.audioOpen()).toBe(false); expect(menus.anyOpen()).toBe(false); }); + + it('reports dockPanelOpen for chip panels but not the volume slider', () => { + const menus = new EmbeddedMpvMenuState(); + + menus.open('volume'); + expect(menus.anyOpen()).toBe(true); + expect(menus.dockPanelOpen()).toBe(false); + + for (const menu of ['audio', 'subtitle', 'speed', 'aspect'] as const) { + menus.open(menu); + expect(menus.dockPanelOpen()).toBe(true); + } + + menus.closeAll(); + expect(menus.dockPanelOpen()).toBe(false); + }); }); describe('EmbeddedMpvFeedback', () => { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts index 93fe8a967..962c42568 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-ui-state.ts @@ -8,8 +8,10 @@ export type EmbeddedMpvMenu = | 'aspect'; /** - * Tracks which menu/popover is currently open and exposes individual signals - * the template binds to. Only one menu can be open at a time. + * Tracks which menu is currently open and exposes individual signals the + * template binds to. Only one menu can be open at a time. Menus render as + * horizontal panels inside the fixed-height controls strip, so open state + * never affects the native MPV view bounds. */ export class EmbeddedMpvMenuState { readonly volumeOpen = signal(false); @@ -27,6 +29,19 @@ export class EmbeddedMpvMenuState { this.aspectOpen() ); + /** + * True while a chip panel morphs the dock row (audio, subtitle, speed, + * aspect — not the inline volume slider). While open, arrow keys walk + * the chips instead of seeking or changing the volume. + */ + readonly dockPanelOpen = computed( + () => + this.audioOpen() || + this.subtitleOpen() || + this.speedOpen() || + this.aspectOpen() + ); + toggle(menu: EmbeddedMpvMenu): void { const target = this.signalFor(menu); const next = !target();