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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-19 07:55:55 +02:00
1 parent 2f8aee72df
commit 4bc6f7e919
35 files changed
+1627 -622

No files matched your search

+1
View File
@@ -453,6 +453,7 @@
},
"EMBEDDED_MPV": {
"PLAYER": {
"BACK": "رجوع",
"RETRY": "إعادة المحاولة",
"STALLED": "يستغرق بدء هذا البث وقتًا أطول من المتوقع.",
"PLAYBACK_FAILED": "فشل التشغيل عبر MPV المضمّن.",
+1
View File
@@ -453,6 +453,7 @@
},
"EMBEDDED_MPV": {
"PLAYER": {
"BACK": "رجوع",
"RETRY": "حاول مرة أخرى",
"STALLED": "هاد البث كياخد وقت كثر من المتوقع باش يبدا.",
"PLAYBACK_FAILED": "فشل التشغيل بـ MPV المدمج.",
+1
View File
@@ -453,6 +453,7 @@
},
"EMBEDDED_MPV": {
"PLAYER": {
"BACK": "Назад",
"RETRY": "Паўтарыць",
"STALLED": "Гэты паток запускаецца даўжэй, чым чакалася.",
"PLAYBACK_FAILED": "Не ўдалося прайграць праз убудаваны MPV.",
+1
View File
@@ -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.",
+1
View File
@@ -453,6 +453,7 @@
},
"EMBEDDED_MPV": {
"PLAYER": {
"BACK": "Πίσω",
"RETRY": "Επανάληψη",
"STALLED": "Η έναρξη αυτής της ροής καθυστερεί περισσότερο από το αναμενόμενο.",
"PLAYBACK_FAILED": "Η αναπαραγωγή με το ενσωματωμένο MPV απέτυχε.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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é.",
+1
View File
@@ -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.",
+1
View File
@@ -453,6 +453,7 @@
},
"EMBEDDED_MPV": {
"PLAYER": {
"BACK": "戻る",
"RETRY": "再試行",
"STALLED": "このストリームの開始に予想より時間がかかっています。",
"PLAYBACK_FAILED": "組み込みMPVでの再生に失敗しました。",
+1
View File
@@ -453,6 +453,7 @@
},
"EMBEDDED_MPV": {
"PLAYER": {
"BACK": "뒤로",
"RETRY": "다시 시도",
"STALLED": "이 스트림의 시작이 예상보다 오래 걸리고 있습니다.",
"PLAYBACK_FAILED": "내장 MPV 재생에 실패했습니다.",
+1
View File
@@ -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.",
+1
View File
@@ -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ę.",
+1
View File
@@ -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.",
+1
View File
@@ -453,6 +453,7 @@
},
"EMBEDDED_MPV": {
"PLAYER": {
"BACK": "Назад",
"RETRY": "Повторить",
"STALLED": "Запуск этого потока занимает больше времени, чем ожидалось.",
"PLAYBACK_FAILED": "Не удалось воспроизвести через встроенный MPV.",
+1
View File
@@ -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.",
+1
View File
@@ -453,6 +453,7 @@
},
"EMBEDDED_MPV": {
"PLAYER": {
"BACK": "返回",
"RETRY": "重试",
"STALLED": "该流的启动时间超出预期。",
"PLAYBACK_FAILED": "嵌入式 MPV 播放失败。",
+1
View File
@@ -453,6 +453,7 @@
},
"EMBEDDED_MPV": {
"PLAYER": {
"BACK": "返回",
"RETRY": "重試",
"STALLED": "此串流的啟動時間比預期長。",
"PLAYBACK_FAILED": "內嵌 MPV 播放失敗。",
+40 -10
View File
@@ -245,7 +245,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,
@@ -532,7 +532,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
@@ -553,7 +553,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<boolean>`. Tracks `MatDialog.afterOpened`/
`afterAllClosed` for dialog-shaped overlays and falls back to a
@@ -561,9 +563,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.
@@ -577,21 +591,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.
@@ -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
@@ -0,0 +1,65 @@
<div class="embedded-mpv-dock-panel" (keydown)="onPanelKeydown($event)">
<button
mat-icon-button
type="button"
class="embedded-mpv-dock-panel__back"
data-test-id="embedded-mpv-dock-panel-back"
(click)="closed.emit()"
[attr.aria-label]="backLabel()"
[matTooltip]="backLabel()"
matTooltipPosition="above"
>
<mat-icon>arrow_back</mat-icon>
</button>
<span class="embedded-mpv-dock-panel__title">{{ panel().title }}</span>
<div class="embedded-mpv-dock-panel__ribbon-shell">
<div
#ribbon
class="embedded-mpv-dock-panel__ribbon"
role="menu"
aria-orientation="horizontal"
[attr.aria-label]="panel().title"
(scroll)="onRibbonScroll()"
(wheel)="onRibbonWheel($event)"
>
@for (
chip of panel().chips;
track chip.id;
let index = $index
) {
<button
#chip
type="button"
role="menuitemradio"
class="embedded-mpv-dock-panel__chip"
[class.embedded-mpv-dock-panel__chip--selected]="
chip.selected
"
[attr.aria-checked]="chip.selected"
[attr.tabindex]="chipTabIndex(chip.selected, index)"
[title]="chip.label"
(click)="chipSelected.emit(chip.id)"
>
@if (chip.selected) {
<mat-icon aria-hidden="true">check</mat-icon>
}
<span class="embedded-mpv-dock-panel__chip-label">{{
chip.label
}}</span>
</button>
}
</div>
<div
class="embedded-mpv-dock-panel__fade embedded-mpv-dock-panel__fade--start"
[class.embedded-mpv-dock-panel__fade--visible]="canScrollStart()"
aria-hidden="true"
></div>
<div
class="embedded-mpv-dock-panel__fade embedded-mpv-dock-panel__fade--end"
[class.embedded-mpv-dock-panel__fade--visible]="canScrollEnd()"
aria-hidden="true"
></div>
</div>
</div>
@@ -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;
}
}
@@ -0,0 +1,169 @@
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<EmbeddedMpvDockPanelComponent>;
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<void>(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('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);
});
});
@@ -0,0 +1,204 @@
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<EmbeddedMpvDockPanelView>();
readonly backLabel = input.required<string>();
readonly chipSelected = output<string>();
readonly closed = output<void>();
readonly canScrollStart = signal(false);
readonly canScrollEnd = signal(false);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly ribbon =
viewChild<ElementRef<HTMLDivElement>>('ribbon');
private readonly chips =
viewChildren<ElementRef<HTMLButtonElement>>('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 === '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'
);
}
}
@@ -0,0 +1,201 @@
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<EmbeddedMpvAudioTrack[]>;
readonly subtitleTracks: Signal<EmbeddedMpvAudioTrack[]>;
readonly selectedSubtitleTrackId: Signal<number | null>;
readonly playbackSpeed: Signal<number>;
readonly aspectOverride: Signal<string>;
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<EmbeddedMpvDockPanelView | null>;
private openerKind: EmbeddedMpvDockPanelKind | null = null;
constructor(private readonly deps: EmbeddedMpvDockPanelDeps) {
this.active = computed(() => this.buildActivePanel());
}
toggle(kind: EmbeddedMpvDockPanelKind): void {
this.openerKind = this.deps.menus.dockPanelOpen() ? null : 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<HTMLElement>(
`[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;
}
}
@@ -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×' },
@@ -17,7 +17,6 @@ export interface EmbeddedMpvLegacyInteractionsDeps {
readonly statusLabel: () => string;
readonly togglePaused: () => void | Promise<void>;
readonly toggleFullscreen: () => void | Promise<void>;
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();
}
@@ -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: `<app-embedded-mpv-player [playback]="playback" />`,
})
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<DockPanelsHostComponent>;
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);
});
});
@@ -111,229 +111,249 @@
[class.embedded-mpv-player__controls--visible]="
controlsAreVisible()
"
[class.embedded-mpv-player__controls--panel]="
dockPanels.active() !== null
"
>
<div class="embedded-mpv-player__transport">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="isLoading()"
(click)="togglePaused()"
[attr.aria-label]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
[matTooltip]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isPaused() ? 'play_arrow' : 'pause'
}}</mat-icon>
</button>
@if (showSeriesNavigation()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-previous-episode"
[disabled]="isLoading() || !canPreviousEpisode()"
(click)="requestPreviousEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-next-episode"
[disabled]="isLoading() || !canNextEpisode()"
(click)="requestNextEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(-10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>replay_10</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>forward_10</mat-icon>
</button>
</div>
<div class="embedded-mpv-player__timeline">
<input
class="embedded-mpv-player__slider"
[style.--slider-progress.%]="
canSeek()
? (timelineValue() /
((session()?.durationSeconds ?? 0) || 1)) *
100
: 0
"
type="range"
min="0"
[max]="session()?.durationSeconds ?? 0"
[value]="timelineValue()"
[disabled]="!canSeek()"
[attr.aria-valuetext]="formatTime(timelineValue())"
(input)="onTimelineInput($event)"
(change)="onTimelineCommit($event)"
@if (dockPanels.active(); as dockPanel) {
<app-embedded-mpv-dock-panel
[panel]="dockPanel"
[backLabel]="dockPanelBackLabel()"
(chipSelected)="dockPanels.select($event)"
(closed)="dockPanels.close()"
/>
<div class="embedded-mpv-player__time">
<span>{{ formatTime(timelineValue()) }}</span>
@if (canSeek()) {
<span>{{
formatTime(session()?.durationSeconds)
}}</span>
} @else if (isLivePlayback()) {
<span
class="embedded-mpv-player__live-badge"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate
"
>
<span
class="embedded-mpv-player__live-dot"
aria-hidden="true"
></span>
LIVE
</span>
} @else {
<span>--:--</span>
}
</div>
@if (recordingStatusText(); as recordingStatus) {
<div
class="embedded-mpv-player__recording-status"
[class.embedded-mpv-player__recording-status--active]="
isRecording()
} @else {
<div class="embedded-mpv-player__transport">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="isLoading()"
(click)="togglePaused()"
[attr.aria-label]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
[attr.aria-live]="isRecording() ? 'off' : 'polite'"
[matTooltip]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isRecording()
? 'fiber_manual_record'
: 'check_circle'
isPaused() ? 'play_arrow' : 'pause'
}}</mat-icon>
<span>{{ recordingStatus }}</span>
</div>
}
</div>
<div class="embedded-mpv-player__actions">
<div
class="embedded-mpv-player__popover-anchor"
(pointerenter)="onVolumeHoverEnter()"
(pointerleave)="onVolumeHoverLeave()"
(focusin)="onVolumeHoverEnter()"
(focusout)="onVolumeHoverLeave()"
(wheel)="onVolumeWheel($event)"
>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleMute()"
[attr.aria-label]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
[matTooltip]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
@if (menus.volumeOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-popover"
role="dialog"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.VOLUME' | translate
"
>
<input
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
[style.--slider-progress.%]="volume() * 100"
type="range"
min="0"
max="1"
step="0.01"
[value]="volume()"
[attr.aria-label]="volumeLabel()"
[attr.aria-valuetext]="volumeLabel()"
(input)="onVolumeInput($event)"
/>
<span class="embedded-mpv-player__volume-value">
{{ volumeLabel() }}
</span>
</div>
}
</div>
@if (hasAudioTracks()) {
<div class="embedded-mpv-player__popover-anchor">
@if (showSeriesNavigation()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-previous-episode"
[disabled]="isLoading() || !canPreviousEpisode()"
(click)="requestPreviousEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE'
| translate
"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-next-episode"
[disabled]="isLoading() || !canNextEpisode()"
(click)="requestNextEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(-10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>replay_10</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
"
matTooltipPosition="above"
>
<mat-icon>forward_10</mat-icon>
</button>
</div>
<div class="embedded-mpv-player__timeline">
<input
class="embedded-mpv-player__slider"
[style.--slider-progress.%]="
canSeek()
? (timelineValue() /
((session()?.durationSeconds ?? 0) ||
1)) *
100
: 0
"
type="range"
min="0"
[max]="session()?.durationSeconds ?? 0"
[value]="timelineValue()"
[disabled]="!canSeek()"
[attr.aria-valuetext]="formatTime(timelineValue())"
(input)="onTimelineInput($event)"
(change)="onTimelineCommit($event)"
/>
<div class="embedded-mpv-player__time">
<span>{{ formatTime(timelineValue()) }}</span>
@if (canSeek()) {
<span>{{
formatTime(session()?.durationSeconds)
}}</span>
} @else if (isLivePlayback()) {
<span
class="embedded-mpv-player__live-badge"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.LIVE_STREAM'
| translate
"
>
<span
class="embedded-mpv-player__live-dot"
aria-hidden="true"
></span>
LIVE
</span>
} @else {
<span>--:--</span>
}
</div>
@if (recordingStatusText(); as recordingStatus) {
<div
class="embedded-mpv-player__recording-status"
[class.embedded-mpv-player__recording-status--active]="
isRecording()
"
[attr.aria-live]="isRecording() ? 'off' : 'polite'"
>
<mat-icon>{{
isRecording()
? 'fiber_manual_record'
: 'check_circle'
}}</mat-icon>
<span>{{ recordingStatus }}</span>
</div>
}
</div>
<div class="embedded-mpv-player__actions">
<div
class="embedded-mpv-player__volume-group"
(pointerenter)="onVolumeHoverEnter()"
(pointerleave)="onVolumeHoverLeave()"
(focusin)="onVolumeHoverEnter()"
(focusout)="onVolumeHoverLeave()"
(wheel)="onVolumeWheel($event)"
>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleMute()"
[attr.aria-label]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
[matTooltip]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
@if (menus.volumeOpen()) {
<div
class="embedded-mpv-player__volume-inline"
role="group"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.VOLUME' | translate
"
>
<input
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
[style.--slider-progress.%]="
volume() * 100
"
type="range"
min="0"
max="1"
step="0.01"
[value]="volume()"
[attr.aria-label]="volumeLabel()"
[attr.aria-valuetext]="volumeLabel()"
(input)="onVolumeInput($event)"
/>
<span
class="embedded-mpv-player__volume-value"
>{{ volumePercent() }}</span
>
</div>
}
</div>
@if (hasAudioTracks()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="audio"
(click)="toggleAudioMenu()"
[attr.aria-expanded]="menus.audioOpen()"
[attr.aria-label]="
@@ -346,59 +366,14 @@
>
<mat-icon>graphic_eq</mat-icon>
</button>
@if (menus.audioOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
| translate
"
>
<div class="embedded-mpv-player__menu-title">
{{
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
>
@for (
track of audioTracks();
track track.id;
let index = $index
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
track.selected
"
(click)="selectAudioTrack(track.id)"
>
<span>{{
trackLabel(track, index)
}}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
}
@if (capabilities().subtitles && hasSubtitleTracks()) {
<div class="embedded-mpv-player__popover-anchor">
@if (capabilities().subtitles && hasSubtitleTracks()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="subtitle"
(click)="toggleSubtitleMenu()"
[attr.aria-expanded]="menus.subtitleOpen()"
[attr.aria-label]="
@@ -415,82 +390,14 @@
: 'subtitles'
}}</mat-icon>
</button>
@if (menus.subtitleOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
>
<div class="embedded-mpv-player__menu-title">
{{
'EMBEDDED_MPV.PLAYER.SUBTITLES'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
>
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
selectedSubtitleTrackId() === null
"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
selectedSubtitleTrackId() === null
"
(click)="selectSubtitleTrack(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
| translate
}}</span>
@if (
selectedSubtitleTrackId() === null
) {
<mat-icon>check</mat-icon>
}
</button>
@for (
track of subtitleTracks();
track track.id;
let index = $index
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
track.selected
"
(click)="
selectSubtitleTrack(track.id)
"
>
<span>{{
subtitleLabel(track, index)
}}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
}
@if (capabilities().playbackSpeed) {
<div class="embedded-mpv-player__popover-anchor">
@if (capabilities().playbackSpeed) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="speed"
(click)="toggleSpeedMenu()"
[attr.aria-expanded]="menus.speedOpen()"
[attr.aria-label]="
@@ -512,60 +419,14 @@
>
<mat-icon>speed</mat-icon>
</button>
@if (menus.speedOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate
"
>
<div class="embedded-mpv-player__menu-title">
{{
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
>
@for (
preset of speedPresets;
track preset.value
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value === playbackSpeed()
"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
preset.value === playbackSpeed()
"
(click)="selectSpeed(preset.value)"
>
<span>{{ preset.label }}</span>
@if (
preset.value === playbackSpeed()
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
}
@if (capabilities().aspectOverride) {
<div class="embedded-mpv-player__popover-anchor">
@if (capabilities().aspectOverride) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="aspect"
(click)="toggleAspectMenu()"
[attr.aria-expanded]="menus.aspectOpen()"
[attr.aria-label]="
@@ -587,105 +448,57 @@
>
<mat-icon>aspect_ratio</mat-icon>
</button>
@if (menus.aspectOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate
"
>
<div class="embedded-mpv-player__menu-title">
{{
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
>
@for (
preset of aspectPresets;
track preset.value
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value ===
aspectOverride()
"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
preset.value ===
aspectOverride()
"
(click)="selectAspect(preset.value)"
>
<span>{{ preset.label }}</span>
@if (
preset.value ===
aspectOverride()
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
}
@if (canRecord()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button embedded-mpv-player__record-button"
[class.embedded-mpv-player__record-button--active]="
isRecording()
"
[disabled]="isLoading()"
(click)="toggleRecording()"
[attr.aria-pressed]="isRecording()"
[attr.aria-label]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
[matTooltip]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isRecording()
? 'stop_circle'
: 'fiber_manual_record'
}}</mat-icon>
</button>
}
@if (canRecord()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button embedded-mpv-player__record-button"
[class.embedded-mpv-player__record-button--active]="
isRecording()
"
[disabled]="isLoading()"
(click)="toggleRecording()"
[attr.aria-pressed]="isRecording()"
[attr.aria-label]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
[matTooltip]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
class="embedded-mpv-player__control-button"
[disabled]="!canFullscreen()"
(click)="toggleFullscreen()"
[attr.aria-label]="fullscreenLabel()"
[matTooltip]="fullscreenLabel()"
matTooltipPosition="above"
>
<mat-icon>{{
isRecording()
? 'stop_circle'
: 'fiber_manual_record'
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
}}</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canFullscreen()"
(click)="toggleFullscreen()"
[attr.aria-label]="fullscreenLabel()"
[matTooltip]="fullscreenLabel()"
matTooltipPosition="above"
>
<mat-icon>{{
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
}}</mat-icon>
</button>
</div>
</div>
}
</div>
}
</div>
@@ -296,15 +296,21 @@
gap: 8px;
}
.embedded-mpv-player__popover-anchor {
position: relative;
.embedded-mpv-player__volume-group {
display: flex;
align-items: center;
}
.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 +404,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 +462,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;
}
}
@@ -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<ElementRef<HTMLDivElement>>('viewport');
readonly playerRoot = viewChild<ElementRef<HTMLDivElement>>('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<void> {
@@ -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);
@@ -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;
@@ -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', () => {
@@ -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();