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();