fix(embedded-mpv): stop native-view video jump by moving control menus into the dock (#1207)

* 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>

* fix(embedded-mpv): address native-view dock review feedback

Resolves the actionable P2 review comments on the dock rework:

- Inline volume no longer clips the dock actions. At sidebar-constrained
  player widths (~480-660px, viewport wider than the 720px breakpoint) the
  new in-flow volume slider widened the non-shrinking actions column and,
  under overflow:hidden, clipped the fullscreen button. Add min-width:0 to
  .embedded-mpv-player__actions and __volume-group so the inline volume (a
  scroll container) compresses its own slider instead of pushing neighbors
  off-edge. Verified in Chromium: fullscreen stays visible down to 480px.
- Space now selects a focused chip. onPanelKeydown stops Space/Enter from
  bubbling to the global shortcut handler (whose Space case preventDefault'd
  the button's native activation and toggled playback) without calling
  preventDefault itself, so the menuitemradio chip activates and emits
  chipSelected. Matches the WAI-ARIA menu activation-key expectation.
- Simplify dock-panel opener tracking: always remember the toggled kind so
  focus restoration is correct if in-panel switching ever becomes reachable
  (currently unreachable — the toggle buttons are removed from the DOM while
  a panel is open); restoreOpenerFocus still no-ops unless focus fell to body.

Not changed: the "closePanels no-ops when unavailable" comment — verified
unreachable (chip selection closes via menus.close() directly, not through
closePopovers; isAvailable() is engine-bound and the native dock only renders
while it is true, with engine handoff calling menus.closeAll()).

Regression test added for Space/Enter chip activation. The volume-overflow
fix is CSS layout (no jsdom layout engine) and was validated in a real
browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-19 18:39:59 +02:00
1 parent c707af1334
commit aa1941cf2c
35 files changed
+1666 -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
@@ -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<boolean>`. 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.
@@ -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,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<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('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);
});
});
@@ -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<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 === ' ' || 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'
);
}
}
@@ -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<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 {
// 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<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>
@@ -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;
}
}
@@ -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();