mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
2f8aee72df
commit
4bc6f7e919
35 files changed
+1627
-622
No files matched your search
@@ -453,6 +453,7 @@
|
||||
},
|
||||
"EMBEDDED_MPV": {
|
||||
"PLAYER": {
|
||||
"BACK": "رجوع",
|
||||
"RETRY": "إعادة المحاولة",
|
||||
"STALLED": "يستغرق بدء هذا البث وقتًا أطول من المتوقع.",
|
||||
"PLAYBACK_FAILED": "فشل التشغيل عبر MPV المضمّن.",
|
||||
|
||||
@@ -453,6 +453,7 @@
|
||||
},
|
||||
"EMBEDDED_MPV": {
|
||||
"PLAYER": {
|
||||
"BACK": "رجوع",
|
||||
"RETRY": "حاول مرة أخرى",
|
||||
"STALLED": "هاد البث كياخد وقت كثر من المتوقع باش يبدا.",
|
||||
"PLAYBACK_FAILED": "فشل التشغيل بـ MPV المدمج.",
|
||||
|
||||
@@ -453,6 +453,7 @@
|
||||
},
|
||||
"EMBEDDED_MPV": {
|
||||
"PLAYER": {
|
||||
"BACK": "Назад",
|
||||
"RETRY": "Паўтарыць",
|
||||
"STALLED": "Гэты паток запускаецца даўжэй, чым чакалася.",
|
||||
"PLAYBACK_FAILED": "Не ўдалося прайграць праз убудаваны MPV.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -453,6 +453,7 @@
|
||||
},
|
||||
"EMBEDDED_MPV": {
|
||||
"PLAYER": {
|
||||
"BACK": "Πίσω",
|
||||
"RETRY": "Επανάληψη",
|
||||
"STALLED": "Η έναρξη αυτής της ροής καθυστερεί περισσότερο από το αναμενόμενο.",
|
||||
"PLAYBACK_FAILED": "Η αναπαραγωγή με το ενσωματωμένο MPV απέτυχε.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -453,6 +453,7 @@
|
||||
},
|
||||
"EMBEDDED_MPV": {
|
||||
"PLAYER": {
|
||||
"BACK": "戻る",
|
||||
"RETRY": "再試行",
|
||||
"STALLED": "このストリームの開始に予想より時間がかかっています。",
|
||||
"PLAYBACK_FAILED": "組み込みMPVでの再生に失敗しました。",
|
||||
|
||||
@@ -453,6 +453,7 @@
|
||||
},
|
||||
"EMBEDDED_MPV": {
|
||||
"PLAYER": {
|
||||
"BACK": "뒤로",
|
||||
"RETRY": "다시 시도",
|
||||
"STALLED": "이 스트림의 시작이 예상보다 오래 걸리고 있습니다.",
|
||||
"PLAYBACK_FAILED": "내장 MPV 재생에 실패했습니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ę.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -453,6 +453,7 @@
|
||||
},
|
||||
"EMBEDDED_MPV": {
|
||||
"PLAYER": {
|
||||
"BACK": "Назад",
|
||||
"RETRY": "Повторить",
|
||||
"STALLED": "Запуск этого потока занимает больше времени, чем ожидалось.",
|
||||
"PLAYBACK_FAILED": "Не удалось воспроизвести через встроенный MPV.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -453,6 +453,7 @@
|
||||
},
|
||||
"EMBEDDED_MPV": {
|
||||
"PLAYER": {
|
||||
"BACK": "返回",
|
||||
"RETRY": "重试",
|
||||
"STALLED": "该流的启动时间超出预期。",
|
||||
"PLAYBACK_FAILED": "嵌入式 MPV 播放失败。",
|
||||
|
||||
@@ -453,6 +453,7 @@
|
||||
},
|
||||
"EMBEDDED_MPV": {
|
||||
"PLAYER": {
|
||||
"BACK": "返回",
|
||||
"RETRY": "重試",
|
||||
"STALLED": "此串流的啟動時間比預期長。",
|
||||
"PLAYBACK_FAILED": "內嵌 MPV 播放失敗。",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+169
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+324
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user