feat(playback): fold player track and playback options into a settings panel

Phase 2 of the "Hybrid" player controls redesign. Audio, subtitles (tracks,
file loading, delay, size, color), quality, speed and aspect ratio move
from five popovers into one surface behind a `tune` button:

- wide dock: subtitle and speed value chips (cyan = on, violet = changed)
  open the panel focused on their group; right-click / long-press on the
  subtitle chip toggles subtitles; the panel slides in beside the video
  and the dock, title and corner shift left; chips and PiP/record fold away
- compact dock: no chips, state dots on `tune`, and a bottom sheet that
  replaces the dock while open; a tap on the video dismisses it
- a choice applies immediately and keeps the panel open; Escape, the close
  button, `tune`, the video surface and outside pointerdown close it
- ControlsMenuState now knows volume / settings / stats plus the focus
  group; ControlsSettings owns availability, on/modified state and the
  transitions; app-player-settings-panel renders the panel and sheet
- SETTINGS / CLOSE_SETTINGS strings in every language; web E2E for the
  wide panel and the compact sheet; contract doc and release note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 21:28:24 +02:00
1 parent c5518fa1ff
commit 93c8c4b148
41 files changed
+2634 -1075

No files matched your search

@@ -0,0 +1,11 @@
---
type: feature
area: playback
highlight: Player settings panel
---
Audio, subtitles, quality, speed and aspect ratio now live in one settings
panel behind a tune button. Wide players show subtitle and speed chips with
their current value and slide the panel in beside the video; small players
get a bottom sheet, and the tune button shows colored dots for anything
turned on or changed.
@@ -0,0 +1,215 @@
import type { Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { expect, test } from './fixtures';
/**
* The shared controls' settings panel: on a wide player the speed chip opens
* the panel beside the video and a choice applies in place; on a compact
* player the tune button opens a bottom sheet that a tap on the video
* dismisses. Runs against the built-in HTML5 player and a local clip so no
* remote media is involved.
*/
const FIXTURE_HOST = 'https://player-settings-fixture.local';
const PLAYLIST = [
'#EXTM3U',
'#EXTINF:-1 group-title="Movies",Settings Clip',
`${FIXTURE_HOST}/clip.mp4`,
].join('\n');
test.use({ serviceWorkers: 'block' });
async function serveClip(page: Page): Promise<void> {
const clip = readFileSync(
join(__dirname, 'fixtures/playback/episode.webm')
);
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
const range = /^bytes=(\d*)-(\d*)$/.exec(
route.request().headers()['range'] ?? ''
);
const last = clip.length - 1;
const start = range?.[1]
? Number(range[1])
: range?.[2]
? Math.max(0, clip.length - Number(range[2]))
: 0;
const end =
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
await route.fulfill({
status: range ? 206 : 200,
headers: {
'content-type': 'video/webm',
'accept-ranges': 'bytes',
'content-length': String(end - start + 1),
...(range
? {
'content-range': `bytes ${start}-${end}/${clip.length}`,
}
: {}),
},
body: clip.subarray(start, end + 1),
});
});
}
async function selectHtml5Player(page: Page): Promise<void> {
await page.goto('/workspace/settings/playback');
const select = page.locator('[data-test-id="select-video-player"]');
await expect(select).toBeVisible();
const previous = await select.innerText();
await select.click();
await page
.getByRole('option', { name: 'HTML5 video player', exact: true })
.click();
if (!previous.includes('HTML5 video player')) {
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
}
}
async function importPlaylist(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
]);
await expect(page.getByText('1 channels')).toBeVisible();
}
async function startClip(page: Page) {
await page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: 'Settings Clip' })
.click();
const view = page.locator('app-web-player-view');
const video = view.locator('video');
await expect
.poll(() =>
video.evaluate(
(el: HTMLVideoElement) =>
Number.isFinite(el.duration) && el.duration > 5
)
)
.toBe(true);
// Reveal the dock and keep it revealed for the assertions below.
await view.hover();
const controls = view.locator('app-player-controls');
await controls.getByRole('button', { name: 'Pause', exact: true }).click();
return { view, video, controls };
}
test('@web @playback settings panel opens from the speed chip and applies in place', async ({
page,
}) => {
test.setTimeout(90_000);
await serveClip(page);
await selectHtml5Player(page);
await importPlaylist(page);
const { video, controls } = await startClip(page);
const speedChip = controls.locator(
'[data-test-id="player-controls-speed-chip"]'
);
await expect(speedChip).toContainText('1×');
await expect(
controls.locator('[data-test-id="player-controls-settings-panel"]')
).toHaveCount(0);
await speedChip.click();
const panel = controls.getByRole('dialog', { name: 'Settings' });
await expect(panel).toBeVisible();
await expect(panel).not.toHaveClass(/player-controls__settings--sheet/);
await expect(controls.locator('.player-controls__bar')).toHaveClass(
/player-controls__bar--panel-open/
);
// Chips fold away while the panel is open; the tune button lights up.
await expect(speedChip).toHaveCount(0);
await expect(
controls.locator('[data-test-id="player-controls-settings-button"]')
).toHaveAttribute('aria-expanded', 'true');
await panel.getByRole('radio', { name: '1.5×', exact: true }).click();
await expect
.poll(() => video.evaluate((el: HTMLVideoElement) => el.playbackRate))
.toBe(1.5);
// The choice keeps the panel open for comparison…
await expect(panel).toBeVisible();
// …and Escape closes it; the chip now reads the modified value.
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(controls.locator('.player-controls__bar')).not.toHaveClass(
/player-controls__bar--panel-open/
);
await expect(
controls.locator('[data-test-id="player-controls-speed-chip"]')
).toContainText('1.5×');
await expect(
controls.locator('[data-test-id="player-controls-speed-chip"]')
).toHaveClass(/player-controls__chip--modified/);
});
test('@web @playback compact player folds the chips into a tune button with a bottom sheet', async ({
page,
}) => {
test.setTimeout(90_000);
await page.setViewportSize({ width: 900, height: 700 });
await serveClip(page);
await selectHtml5Player(page);
await importPlaylist(page);
const { view, controls } = await startClip(page);
await expect(controls.locator('.player-controls__bar')).toHaveClass(
/player-controls__bar--compact/
);
await expect(
controls.locator('[data-test-id="player-controls-speed-chip"]')
).toHaveCount(0);
const tune = controls.locator(
'[data-test-id="player-controls-settings-button"]'
);
await tune.click();
const sheet = controls.getByRole('dialog', { name: 'Settings' });
await expect(sheet).toBeVisible();
await expect(sheet).toHaveClass(/player-controls__settings--sheet/);
await expect(controls.locator('.player-controls__bar')).toHaveClass(
/player-controls__bar--sheet-open/
);
await sheet.getByRole('radio', { name: '2×', exact: true }).click();
await expect
.poll(() =>
view
.locator('video')
.evaluate((el: HTMLVideoElement) => el.playbackRate)
)
.toBe(2);
// A modified value shows as a violet dot once the sheet is closed.
await sheet.locator('[data-test-id="player-settings-close"]').click();
await expect(sheet).toHaveCount(0);
await expect(
controls
.locator('[data-test-id="player-controls-settings-dots"]')
.locator('.player-controls__tune-dot--violet')
).toHaveCount(1);
// Reopen and dismiss by tapping the video: the tap closes the sheet
// instead of toggling playback.
await tune.click();
await expect(sheet).toBeVisible();
await view
.locator('video')
.click({ position: { x: 40, y: 40 }, force: true });
await expect(sheet).toHaveCount(0);
await expect(
controls.getByRole('button', { name: 'Play', exact: true })
).toBeVisible();
});
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "البحث في {{title}}",
"CLEAR_SEARCH": "مسح البحث",
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
"SETTINGS": "الإعدادات",
"CLOSE_SETTINGS": "إغلاق الإعدادات",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا",
"SUBTITLE_DELAY_INCREASE": "عرض الترجمة لاحقًا",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "قلّب ف {{title}}",
"CLEAR_SEARCH": "مسح البحث",
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
"SETTINGS": "الإعدادات",
"CLOSE_SETTINGS": "سد الإعدادات",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل",
"SUBTITLE_DELAY_INCREASE": "ورّي الترجمة من بعد",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Пошук у {{title}}",
"CLEAR_SEARCH": "Ачысціць пошук",
"LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…",
"SETTINGS": "Налады",
"CLOSE_SETTINGS": "Закрыць налады",
"SUBTITLE_DELAY": "Затрымка субтытраў",
"SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней",
"SUBTITLE_DELAY_INCREASE": "Паказваць субтытры пазней",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "In {{title}} suchen",
"CLEAR_SEARCH": "Suche löschen",
"LOAD_SUBTITLE_FILE": "Untertiteldatei laden…",
"SETTINGS": "Einstellungen",
"CLOSE_SETTINGS": "Einstellungen schließen",
"SUBTITLE_DELAY": "Untertitelverzögerung",
"SUBTITLE_DELAY_DECREASE": "Untertitel früher anzeigen",
"SUBTITLE_DELAY_INCREASE": "Untertitel später anzeigen",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Αναζήτηση σε {{title}}",
"CLEAR_SEARCH": "Καθαρισμός αναζήτησης",
"LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…",
"SETTINGS": "Ρυθμίσεις",
"CLOSE_SETTINGS": "Κλείσιμο ρυθμίσεων",
"SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων",
"SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα",
"SUBTITLE_DELAY_INCREASE": "Εμφάνιση υποτίτλων αργότερα",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Search in {{title}}",
"CLEAR_SEARCH": "Clear search",
"LOAD_SUBTITLE_FILE": "Load subtitle file…",
"SETTINGS": "Settings",
"CLOSE_SETTINGS": "Close settings",
"SUBTITLE_DELAY": "Subtitle delay",
"SUBTITLE_DELAY_DECREASE": "Show subtitles earlier",
"SUBTITLE_DELAY_INCREASE": "Show subtitles later",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Buscar en {{title}}",
"CLEAR_SEARCH": "Borrar búsqueda",
"LOAD_SUBTITLE_FILE": "Cargar archivo de subtítulos…",
"SETTINGS": "Ajustes",
"CLOSE_SETTINGS": "Cerrar ajustes",
"SUBTITLE_DELAY": "Retardo de subtítulos",
"SUBTITLE_DELAY_DECREASE": "Mostrar subtítulos antes",
"SUBTITLE_DELAY_INCREASE": "Mostrar subtítulos después",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Rechercher dans {{title}}",
"CLEAR_SEARCH": "Effacer la recherche",
"LOAD_SUBTITLE_FILE": "Charger un fichier de sous-titres…",
"SETTINGS": "Réglages",
"CLOSE_SETTINGS": "Fermer les réglages",
"SUBTITLE_DELAY": "Décalage des sous-titres",
"SUBTITLE_DELAY_DECREASE": "Afficher les sous-titres plus tôt",
"SUBTITLE_DELAY_INCREASE": "Afficher les sous-titres plus tard",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Keresés itt: {{title}}",
"CLEAR_SEARCH": "Keresés törlése",
"LOAD_SUBTITLE_FILE": "Feliratfájl betöltése…",
"SETTINGS": "Beállítások",
"CLOSE_SETTINGS": "Beállítások bezárása",
"SUBTITLE_DELAY": "Felirat késleltetése",
"SUBTITLE_DELAY_DECREASE": "Feliratok megjelenítése korábban",
"SUBTITLE_DELAY_INCREASE": "Feliratok megjelenítése később",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Cerca in {{title}}",
"CLEAR_SEARCH": "Cancella ricerca",
"LOAD_SUBTITLE_FILE": "Carica file sottotitoli…",
"SETTINGS": "Impostazioni",
"CLOSE_SETTINGS": "Chiudi impostazioni",
"SUBTITLE_DELAY": "Ritardo sottotitoli",
"SUBTITLE_DELAY_DECREASE": "Mostra i sottotitoli prima",
"SUBTITLE_DELAY_INCREASE": "Mostra i sottotitoli dopo",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "{{title}} 内を検索",
"CLEAR_SEARCH": "検索をクリア",
"LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…",
"SETTINGS": "設定",
"CLOSE_SETTINGS": "設定を閉じる",
"SUBTITLE_DELAY": "字幕の遅延",
"SUBTITLE_DELAY_DECREASE": "字幕を早く表示",
"SUBTITLE_DELAY_INCREASE": "字幕を遅く表示",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "{{title}}에서 검색",
"CLEAR_SEARCH": "검색 지우기",
"LOAD_SUBTITLE_FILE": "자막 파일 불러오기…",
"SETTINGS": "설정",
"CLOSE_SETTINGS": "설정 닫기",
"SUBTITLE_DELAY": "자막 지연",
"SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시",
"SUBTITLE_DELAY_INCREASE": "자막을 더 늦게 표시",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Zoeken in {{title}}",
"CLEAR_SEARCH": "Zoekopdracht wissen",
"LOAD_SUBTITLE_FILE": "Ondertitelbestand laden…",
"SETTINGS": "Instellingen",
"CLOSE_SETTINGS": "Instellingen sluiten",
"SUBTITLE_DELAY": "Ondertitelvertraging",
"SUBTITLE_DELAY_DECREASE": "Ondertitels eerder tonen",
"SUBTITLE_DELAY_INCREASE": "Ondertitels later tonen",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Szukaj w {{title}}",
"CLEAR_SEARCH": "Wyczyść wyszukiwanie",
"LOAD_SUBTITLE_FILE": "Wczytaj plik napisów…",
"SETTINGS": "Ustawienia",
"CLOSE_SETTINGS": "Zamknij ustawienia",
"SUBTITLE_DELAY": "Opóźnienie napisów",
"SUBTITLE_DELAY_DECREASE": "Pokaż napisy wcześniej",
"SUBTITLE_DELAY_INCREASE": "Pokaż napisy później",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Pesquisar em {{title}}",
"CLEAR_SEARCH": "Limpar pesquisa",
"LOAD_SUBTITLE_FILE": "Carregar ficheiro de legendas…",
"SETTINGS": "Configurações",
"CLOSE_SETTINGS": "Fechar configurações",
"SUBTITLE_DELAY": "Atraso das legendas",
"SUBTITLE_DELAY_DECREASE": "Mostrar legendas mais cedo",
"SUBTITLE_DELAY_INCREASE": "Mostrar legendas mais tarde",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "Поиск в {{title}}",
"CLEAR_SEARCH": "Очистить поиск",
"LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…",
"SETTINGS": "Настройки",
"CLOSE_SETTINGS": "Закрыть настройки",
"SUBTITLE_DELAY": "Задержка субтитров",
"SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше",
"SUBTITLE_DELAY_INCREASE": "Показывать субтитры позже",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "{{title}} içinde ara",
"CLEAR_SEARCH": "Aramayı temizle",
"LOAD_SUBTITLE_FILE": "Altyazı dosyası yükle…",
"SETTINGS": "Ayarlar",
"CLOSE_SETTINGS": "Ayarları kapat",
"SUBTITLE_DELAY": "Altyazı gecikmesi",
"SUBTITLE_DELAY_DECREASE": "Altyazıları daha erken göster",
"SUBTITLE_DELAY_INCREASE": "Altyazıları daha geç göster",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "在 {{title}} 中搜索",
"CLEAR_SEARCH": "清除搜索",
"LOAD_SUBTITLE_FILE": "加载字幕文件…",
"SETTINGS": "设置",
"CLOSE_SETTINGS": "关闭设置",
"SUBTITLE_DELAY": "字幕延迟",
"SUBTITLE_DELAY_DECREASE": "提前显示字幕",
"SUBTITLE_DELAY_INCREASE": "延后显示字幕",
+2
View File
@@ -621,6 +621,8 @@
"SEARCH_IN": "在 {{title}} 中搜尋",
"CLEAR_SEARCH": "清除搜尋",
"LOAD_SUBTITLE_FILE": "載入字幕檔案…",
"SETTINGS": "設定",
"CLOSE_SETTINGS": "關閉設定",
"SUBTITLE_DELAY": "字幕延遲",
"SUBTITLE_DELAY_DECREASE": "提前顯示字幕",
"SUBTITLE_DELAY_INCREASE": "延後顯示字幕",
+73 -12
View File
@@ -289,7 +289,11 @@ It owns only transient presentation behavior:
- `ControlsSurface` — pointer/click/double-click surface interactions;
- `ControlsTimeline` — scrub state and timeline projections;
- `ControlsTimelineHover` — the time under the pointer over the timeline;
- `ControlsLayout` — the compact/wide dock mode from the host's width; and
- `ControlsLayout` — the compact/wide dock mode from the host's width;
- `ControlsSettings` — the settings panel's groups, on/modified state and
open/close transitions (`controls-settings-groups.ts` holds the pure
group-availability rule);
- `app-player-settings-panel` — the panel / bottom sheet presentation; and
- `controls-view-model.ts` — derived display state.
### The dock
@@ -321,7 +325,8 @@ app's `--app-selection-color` is a different blue that would fight the video.
the hover/tap popover in the compact mode — inline, the button is a
plain mute toggle for every pointer type (`buttonClick(event,
{ inlineSlider: true })`). Center: previous episode · −10s · **play** ·
+10s · next episode. Right: the capability actions, end-aligned.
+10s · next episode. Right: the value chips, the `tune` button,
recording, picture-in-picture and fullscreen, end-aligned.
- **Play button** (`.player-controls__play`, `data-test-id
="player-controls-play"`): a 52px filled accent circle with a white glyph,
not a Material icon button. Hover darkens the fill rather than lightening
@@ -331,6 +336,55 @@ app's `--app-selection-color` is a different blue that would fight the video.
Material's `--mat-icon-button-*` tokens; their hover is a flat
`rgba(255,255,255,.1)` layer.
### Settings panel
Every track, quality, speed and aspect choice lives behind one **`tune`**
button (`data-test-id="player-controls-settings-button"`) in a single
surface, `app-player-settings-panel` (`player-settings-panel.component.*`),
instead of five popovers. `ControlsMenuState` knows three menus — `volume`,
`settings`, `stats` — and `settingsFocus`, the group the panel was opened
for. `ControlsSettings` derives, from capabilities and state, which groups
exist (`getSettingsGroupAvailability`: audio needs more than one track,
subtitles a track or `externalSubtitles`, quality more than one level,
speed and aspect their capabilities), whether anything is on or changed,
and owns open/toggle/close; the `tune` button and the panel render only
while at least one group exists, and the availability reconciliation closes
the panel the moment the last group disappears.
- **Wide dock**: two **value chips** precede `tune` — subtitles
(`closed_caption` + the selected track's label, or "Off") and speed
(`speed` + `1.25×`). Audio and aspect ratio have no chip: they are
panel-only. A chip click opens the panel **focused on its group**
(`settingsFocus`; the group scrolls into view and wears a brief ring);
right-click or long-press on the subtitle chip toggles subtitles without
opening anything (`ControlsSettings.toggleSubtitles`: the first embedded
track on, `-1` off; with no track to turn on it opens the group so the
file loader is reachable). While the panel is open the dock, title and
corner shift left by the panel's width (`--panel-open` modifiers,
`right: 370px`), the chips and the picture-in-picture / recording buttons
fold away, `tune` fills in the accent color, and fullscreen stays.
- **Compact dock**: no chips; `tune` carries **state dots** (5px, cyan when
subtitles are on or a non-default audio track is selected, violet when
speed, aspect or manual quality differ from their default) and the panel
opens as a **bottom sheet** (`--sheet` modifier: grip, two-column rows,
24px segmented items) that replaces the dock while open. Picture-in-
picture and recording stay in the compact dock — the mock shows only
`tune` + fullscreen there, but those two are engine features a viewer
needs without opening anything.
- **Inside**: list groups (audio, subtitles, quality) use `menuitemradio`
rows with a check mark and a cyan selection; segmented groups (speed,
aspect) use `radio` items with a violet selection, and a selected default
(`1×`, the first aspect preset) stays neutral. The subtitle group carries
the load-file action and the delay / size / color sections that the
popover used to hold (same `player-controls-load-subtitle`,
`player-controls-subtitle-delay`, `player-controls-subtitle-style` test
ids). A choice applies immediately and **keeps the panel open** —
`ControlsMenuSelection` no longer closes anything — so alternatives can be
compared against the running video; Escape, the close button, the `tune`
button, a click on the video surface or an outside pointerdown close it.
- **Colors** follow the color-as-state rule of the dock: cyan means "on",
violet means "changed", and neutral rows/items read as the default.
### Stream info popover
An `info` button in the **top-right corner** of the overlay opens a popover with
@@ -734,7 +788,8 @@ instance owns shortcuts initially. Pointer, focus, or control interaction
activates that instance through the normal reveal path. If the active instance
becomes unavailable, playback shortcuts fall back to the most recently attached
available instance; detaching the active instance also transfers ownership.
Escape remains a global dismissal action and closes popovers on every mounted
Escape remains a global dismissal action and closes popovers and the settings
panel on every mounted
controls instance.
Auto-hide pauses while the pointer is over the controls bar or keyboard focus
@@ -1258,11 +1313,11 @@ regressions:
## Advanced subtitle support
The subtitle popover carries three capability-gated extensions beyond track
selection (#1408): loading an external subtitle file, adjusting the subtitle
timing offset, and styling subtitle text (size + color). Each is honest per
engine — an engine that cannot support a control simply never advertises the
capability, and the UI is not rendered.
The subtitles group of the settings panel carries three capability-gated
extensions beyond track selection (#1408): loading an external subtitle
file, adjusting the subtitle timing offset, and styling subtitle text (size
+ color). Each is honest per engine — an engine that cannot support a control
simply never advertises the capability, and the UI is not rendered.
Contract surface:
@@ -1273,11 +1328,12 @@ Contract surface:
environment's picker), `setSubtitleDelay(seconds)`, and
`setSubtitleStyle(style)`.
The subtitle menu stays reachable with an empty track list whenever
The subtitles group stays reachable with an empty track list whenever
`externalSubtitles` is set — loading a file is what creates the first track.
Delay and style rows keep the popover open, because these settings are tuned
iteratively against the running video (`ControlsSubtitleSettings` owns those
interactions); the load action closes it because a file dialog opens on top.
Delay and style rows keep the panel open like every other choice, because
these settings are tuned iteratively against the running video
(`ControlsSubtitleSettings` owns those interactions); the file dialog the
load action opens sits on top of the still-open panel.
Persistence: the style (size/color) is a cross-engine preference stored under
the `subtitleStyle` localStorage key (`subtitle-style.ts`), the same mechanism
@@ -1439,6 +1495,11 @@ libs/ui/playback/src/lib/player-controls/
├── controls-format.utils.ts
├── controls-layout.ts
├── controls-timeline-hover.ts
├── controls-settings.ts
├── controls-settings-groups.ts
├── player-settings-panel.component.ts
├── player-settings-panel.component.html
├── player-settings-panel.component.scss
├── controls-fullscreen.ts
├── controls-menu-selection.ts
├── controls-menu-state.ts
@@ -1,12 +1,8 @@
import { ControlsMenuSelection } from './controls-menu-selection';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsVisibility } from './controls-visibility';
import type { PlayerControlsCommands } from './player-controls.model';
describe('ControlsMenuSelection', () => {
let commands: jest.Mocked<PlayerControlsCommands>;
let menus: ControlsMenuState;
let visibility: ControlsVisibility;
let revealSticky: jest.Mock;
let selection: ControlsMenuSelection;
@@ -27,75 +23,35 @@ describe('ControlsMenuSelection', () => {
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
menus = new ControlsMenuState();
visibility = new ControlsVisibility(() => false);
jest.spyOn(visibility, 'scheduleHide');
revealSticky = jest.fn();
selection = new ControlsMenuSelection({
commands: () => commands,
menus,
visibility,
revealSticky,
});
});
it('toggles a menu through the menu state', () => {
selection.toggle('audio');
expect(menus.audioOpen()).toBe(true);
selection.toggle('audio');
expect(menus.audioOpen()).toBe(false);
});
it('selects an audio track: reveal sticky, command, close menu, reschedule hide', () => {
menus.open('audio');
it('selects an audio track: reveal sticky, then the command', () => {
selection.audioTrack(3);
expect(revealSticky).toHaveBeenCalledTimes(1);
expect(commands.setAudioTrack).toHaveBeenCalledWith(3);
expect(menus.audioOpen()).toBe(false);
expect(visibility.scheduleHide).toHaveBeenCalledTimes(1);
});
it('selects a subtitle track and closes the subtitle menu', () => {
menus.open('subtitle');
selection.subtitleTrack(-1);
it.each([
['subtitleTrack', -1, 'setSubtitleTrack'],
['qualityLevel', -1, 'setQualityLevel'],
['speed', 1.5, 'setPlaybackSpeed'],
['aspect', '16:9', 'setAspectRatio'],
] as const)('routes %s to the engine command', (method, value, command) => {
(selection[method] as (arg: unknown) => void)(value);
expect(commands.setSubtitleTrack).toHaveBeenCalledWith(-1);
expect(menus.subtitleOpen()).toBe(false);
expect(visibility.scheduleHide).toHaveBeenCalled();
expect(revealSticky).toHaveBeenCalledTimes(1);
expect(commands[command]).toHaveBeenCalledWith(value);
});
it('selects a quality level and closes the quality menu', () => {
menus.open('quality');
selection.qualityLevel(-1);
it('opens the subtitle file picker', () => {
selection.externalSubtitle();
expect(commands.setQualityLevel).toHaveBeenCalledWith(-1);
expect(menus.qualityOpen()).toBe(false);
expect(visibility.scheduleHide).toHaveBeenCalled();
});
it('applies a playback speed and closes the speed menu', () => {
menus.open('speed');
selection.speed(1.5);
expect(commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5);
expect(menus.speedOpen()).toBe(false);
});
it('applies an aspect ratio and closes the aspect menu', () => {
menus.open('aspect');
selection.aspect('16:9');
expect(commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(menus.aspectOpen()).toBe(false);
});
it('only closes the menu the selection belongs to', () => {
menus.open('volume');
selection.speed(2);
// Closing 'speed' must not blindly close every popover.
expect(menus.volumeOpen()).toBe(true);
expect(commands.addExternalSubtitleFile).toHaveBeenCalledTimes(1);
});
});
@@ -1,58 +1,47 @@
import type { ControlsMenuState } from './controls-menu-state';
import type { ControlsVisibility } from './controls-visibility';
import type { PlayerControlsCommands } from './player-controls.model';
type MenuKey = 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect';
export interface MenuSelectionDeps {
commands: () => PlayerControlsCommands;
menus: ControlsMenuState;
visibility: ControlsVisibility;
/** Reveal without rescheduling the auto-hide (kept open while choosing). */
/** Reveal without rescheduling the auto-hide (the panel stays open). */
revealSticky: () => void;
}
/**
* Owns track/speed/aspect menu selection: reveal-without-hide, run the command,
* close the menu, then reschedule the auto-hide. Keeps the component lean while
* the template still binds to thin delegating methods.
* Owns track/quality/speed/aspect selection from the settings panel: reveal
* without hiding, then run the command. The panel deliberately stays open —
* a choice applies immediately and is judged against the running video, so
* closing it on every click would cost a reopen per comparison.
*/
export class ControlsMenuSelection {
constructor(private readonly deps: MenuSelectionDeps) {}
toggle(menu: MenuKey): void {
this.deps.menus.toggle(menu);
}
audioTrack(trackId: number): void {
this.apply('audio', (c) => c.setAudioTrack(trackId));
this.apply((c) => c.setAudioTrack(trackId));
}
subtitleTrack(trackId: number): void {
this.apply('subtitle', (c) => c.setSubtitleTrack(trackId));
this.apply((c) => c.setSubtitleTrack(trackId));
}
/** Opens the engine's subtitle file picker and closes the popover. */
/** Opens the engine's subtitle file picker. */
externalSubtitle(): void {
this.apply('subtitle', (c) => c.addExternalSubtitleFile());
this.apply((c) => c.addExternalSubtitleFile());
}
qualityLevel(levelId: number): void {
this.apply('quality', (c) => c.setQualityLevel(levelId));
this.apply((c) => c.setQualityLevel(levelId));
}
speed(value: number): void {
this.apply('speed', (c) => c.setPlaybackSpeed(value));
this.apply((c) => c.setPlaybackSpeed(value));
}
aspect(value: string): void {
this.apply('aspect', (c) => c.setAspectRatio(value));
this.apply((c) => c.setAspectRatio(value));
}
private apply(menu: MenuKey, run: (c: PlayerControlsCommands) => void): void {
private apply(run: (c: PlayerControlsCommands) => void): void {
this.deps.revealSticky();
run(this.deps.commands());
this.deps.menus.close(menu);
this.deps.visibility.scheduleHide();
}
}
@@ -12,31 +12,43 @@ describe('ControlsMenuState', () => {
expect(menus.volumeOpen()).toBe(true);
expect(menus.anyOpen()).toBe(true);
menus.open('audio');
menus.open('settings');
expect(menus.volumeOpen()).toBe(false);
expect(menus.audioOpen()).toBe(true);
expect(menus.settingsOpen()).toBe(true);
menus.toggle('audio');
expect(menus.audioOpen()).toBe(false);
menus.toggle('settings');
expect(menus.settingsOpen()).toBe(false);
expect(menus.anyOpen()).toBe(false);
});
it('closes all menus', () => {
const menus = new ControlsMenuState();
menus.open('speed');
menus.open('settings');
menus.closeAll();
expect(menus.anyOpen()).toBe(false);
});
it.each([
'volume',
'audio',
'subtitle',
'quality',
'speed',
'aspect',
'stats',
] as const)(
it('remembers the group the settings panel was opened for', () => {
const menus = new ControlsMenuState();
menus.openSettings('speed');
expect(menus.settingsOpen()).toBe(true);
expect(menus.settingsFocus()).toBe('speed');
// Re-opening on another group keeps the panel and moves the focus.
menus.openSettings('subtitles');
expect(menus.settingsOpen()).toBe(true);
expect(menus.settingsFocus()).toBe('subtitles');
menus.close('settings');
expect(menus.settingsFocus()).toBeNull();
menus.openSettings('audio');
menus.closeAll();
expect(menus.settingsFocus()).toBeNull();
});
it.each(['volume', 'settings', 'stats'] as const)(
'closes an open %s menu when it becomes unavailable',
(menu) => {
const menus = new ControlsMenuState();
@@ -44,11 +56,7 @@ describe('ControlsMenuState', () => {
const changed = menus.reconcile({
volume: menu !== 'volume',
audio: menu !== 'audio',
subtitle: menu !== 'subtitle',
quality: menu !== 'quality',
speed: menu !== 'speed',
aspect: menu !== 'aspect',
settings: menu !== 'settings',
stats: menu !== 'stats',
});
@@ -59,49 +67,41 @@ describe('ControlsMenuState', () => {
it('leaves an available menu open without reporting a change', () => {
const menus = new ControlsMenuState();
menus.open('speed');
menus.open('settings');
expect(
menus.reconcile({
volume: true,
audio: true,
subtitle: true,
quality: true,
speed: true,
aspect: true,
stats: true,
})
menus.reconcile({ volume: true, settings: true, stats: true })
).toBe(false);
expect(menus.speedOpen()).toBe(true);
expect(menus.settingsOpen()).toBe(true);
});
it('closes every unavailable menu if state was made inconsistent', () => {
const menus = new ControlsMenuState();
menus.volumeOpen.set(true);
menus.speedOpen.set(true);
menus.settingsOpen.set(true);
expect(
menus.reconcile({
volume: false,
audio: false,
subtitle: false,
quality: false,
speed: false,
aspect: false,
stats: false,
})
menus.reconcile({ volume: false, settings: false, stats: false })
).toBe(true);
expect(menus.anyOpen()).toBe(false);
});
it.each([
['volume', { volume: false }, {}],
['audio', {}, { audioTracks: [] }],
['subtitle', {}, { subtitleTracks: [] }],
['quality', {}, { qualityLevels: [] }],
['speed', { playbackSpeed: false }, {}],
['aspect', { aspectRatio: false }, {}],
['stats', { streamStats: false }, {}],
[
'settings',
{
audioTracks: false,
subtitles: false,
externalSubtitles: false,
qualityLevels: false,
playbackSpeed: false,
aspectRatio: false,
},
{},
],
['settings', { playbackSpeed: false, aspectRatio: false }, {}],
] as const)(
'maps runtime controller state to %s menu availability',
(menu, capabilityOverrides, stateOverrides) => {
@@ -113,32 +113,48 @@ describe('ControlsMenuState', () => {
{
...DEFAULT_PLAYER_CAPABILITIES,
volume: true,
audioTracks: true,
subtitles: true,
qualityLevels: true,
streamStats: true,
playbackSpeed: true,
aspectRatio: true,
streamStats: true,
...capabilityOverrides,
},
{
...createEmptyControlsState(),
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
subtitleTracks: [
{ id: 1, label: 'English', selected: true },
],
qualityLevels: [
{ id: 0, label: '1080p', selected: false },
{ id: 1, label: '720p', selected: false },
],
...stateOverrides,
}
{ ...createEmptyControlsState(), ...stateOverrides }
);
expect(menus.anyOpen()).toBe(false);
}
);
it('keeps the settings panel while any group still exists', () => {
const menus = new ControlsMenuState();
menus.openSettings('speed');
menus.reconcileControllerAvailability(
true,
{
...DEFAULT_PLAYER_CAPABILITIES,
playbackSpeed: true,
audioTracks: true,
},
{
...createEmptyControlsState(),
audioTracks: [{ id: 1, label: 'Only', selected: true }],
}
);
expect(menus.settingsOpen()).toBe(true);
});
it('closes every menu while controls are hidden', () => {
const menus = new ControlsMenuState();
menus.open('settings');
menus.reconcileControllerAvailability(
false,
{ ...DEFAULT_PLAYER_CAPABILITIES, playbackSpeed: true },
createEmptyControlsState()
);
expect(menus.anyOpen()).toBe(false);
});
});
@@ -1,18 +1,15 @@
import { computed, signal } from '@angular/core';
import {
anySettingsGroupAvailable,
getSettingsGroupAvailability,
type SettingsGroup,
} from './controls-settings-groups';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
} from './player-controls.model';
const CONTROL_MENUS = [
'volume',
'audio',
'subtitle',
'quality',
'speed',
'aspect',
'stats',
] as const;
const CONTROL_MENUS = ['volume', 'settings', 'stats'] as const;
export type ControlsMenu = (typeof CONTROL_MENUS)[number];
export type ControlsMenuAvailability = Readonly<Record<ControlsMenu, boolean>>;
@@ -24,49 +21,30 @@ function getControlsMenuAvailability(
): ControlsMenuAvailability {
return {
volume: showControls && capabilities.volume,
audio:
settings:
showControls &&
capabilities.audioTracks &&
state.audioTracks.length > 1,
// External subtitle loading keeps the menu reachable with an empty
// track list — the "Load subtitle file…" action is how the first
// track appears.
subtitle:
showControls &&
((capabilities.subtitles && state.subtitleTracks.length > 0) ||
capabilities.externalSubtitles),
quality:
showControls &&
capabilities.qualityLevels &&
state.qualityLevels.length > 1,
speed: showControls && capabilities.playbackSpeed,
aspect: showControls && capabilities.aspectRatio,
anySettingsGroupAvailable(
getSettingsGroupAvailability(capabilities, state)
),
stats: showControls && capabilities.streamStats,
};
}
/**
* Tracks which menu/popover is currently open and exposes individual signals
* the template binds to. Only one menu can be open at a time.
* the template binds to. Only one menu can be open at a time. The settings
* panel is one menu: every track, quality, speed and aspect choice lives
* inside it, and `settingsFocus` names the group it was opened for (a chip
* click), so the panel can bring that group into view.
*/
export class ControlsMenuState {
readonly volumeOpen = signal(false);
readonly audioOpen = signal(false);
readonly subtitleOpen = signal(false);
readonly qualityOpen = signal(false);
readonly speedOpen = signal(false);
readonly aspectOpen = signal(false);
readonly settingsOpen = signal(false);
readonly statsOpen = signal(false);
readonly settingsFocus = signal<SettingsGroup | null>(null);
readonly anyOpen = computed(
() =>
this.volumeOpen() ||
this.audioOpen() ||
this.subtitleOpen() ||
this.qualityOpen() ||
this.speedOpen() ||
this.aspectOpen() ||
this.statsOpen()
() => this.volumeOpen() || this.settingsOpen() || this.statsOpen()
);
toggle(menu: ControlsMenu): void {
@@ -84,18 +62,24 @@ export class ControlsMenuState {
this.signalFor(menu).set(true);
}
/** Opens the settings panel on one group (or wherever it was). */
openSettings(group: SettingsGroup | null = null): void {
this.open('settings');
this.settingsFocus.set(group);
}
close(menu: ControlsMenu): void {
this.signalFor(menu).set(false);
if (menu === 'settings') {
this.settingsFocus.set(null);
}
}
closeAll(): void {
this.volumeOpen.set(false);
this.audioOpen.set(false);
this.subtitleOpen.set(false);
this.qualityOpen.set(false);
this.speedOpen.set(false);
this.aspectOpen.set(false);
this.settingsOpen.set(false);
this.statsOpen.set(false);
this.settingsFocus.set(null);
}
reconcile(availability: ControlsMenuAvailability): boolean {
@@ -103,7 +87,7 @@ export class ControlsMenuState {
for (const menu of CONTROL_MENUS) {
const open = this.signalFor(menu);
if (open() && !availability[menu]) {
open.set(false);
this.close(menu);
changed = true;
}
}
@@ -124,16 +108,8 @@ export class ControlsMenuState {
switch (menu) {
case 'volume':
return this.volumeOpen;
case 'audio':
return this.audioOpen;
case 'subtitle':
return this.subtitleOpen;
case 'quality':
return this.qualityOpen;
case 'speed':
return this.speedOpen;
case 'aspect':
return this.aspectOpen;
case 'settings':
return this.settingsOpen;
case 'stats':
return this.statsOpen;
}
@@ -0,0 +1,47 @@
import type {
PlayerControlsCapabilities,
PlayerControlsState,
} from './player-controls.model';
/** The groups of the settings panel, in the order they are rendered. */
export const SETTINGS_GROUPS = [
'audio',
'subtitles',
'quality',
'speed',
'aspect',
] as const;
export type SettingsGroup = (typeof SETTINGS_GROUPS)[number];
export type SettingsGroupAvailability = Readonly<
Record<SettingsGroup, boolean>
>;
/**
* Which settings groups the current engine and stream offer. A group with
* nothing to choose from is not rendered, exactly like the popover buttons
* it replaces were not.
*/
export function getSettingsGroupAvailability(
capabilities: PlayerControlsCapabilities,
state: PlayerControlsState
): SettingsGroupAvailability {
return {
audio: capabilities.audioTracks && state.audioTracks.length > 1,
// External subtitle loading keeps the group reachable with an empty
// track list — the "Load subtitle file…" action is how the first
// track appears.
subtitles:
(capabilities.subtitles && state.subtitleTracks.length > 0) ||
capabilities.externalSubtitles,
quality: capabilities.qualityLevels && state.qualityLevels.length > 1,
speed: capabilities.playbackSpeed,
aspect: capabilities.aspectRatio,
};
}
export function anySettingsGroupAvailable(
availability: SettingsGroupAvailability
): boolean {
return SETTINGS_GROUPS.some((group) => availability[group]);
}
@@ -0,0 +1,194 @@
import { WritableSignal, signal } from '@angular/core';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsSettings } from './controls-settings';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
} from './player-controls.model';
describe('ControlsSettings', () => {
let state: WritableSignal<PlayerControlsState>;
let capabilities: WritableSignal<PlayerControlsCapabilities>;
let showControls: WritableSignal<boolean>;
let menus: ControlsMenuState;
let commands: jest.Mocked<PlayerControlsCommands>;
let reveal: jest.Mock;
let settings: ControlsSettings;
const setState = (overrides: Partial<PlayerControlsState>) =>
state.set({ ...createEmptyControlsState(), ...overrides });
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
beforeEach(() => {
state = signal(createEmptyControlsState());
capabilities = signal({ ...DEFAULT_PLAYER_CAPABILITIES });
showControls = signal(true);
menus = new ControlsMenuState();
commands = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
reveal = jest.fn();
settings = new ControlsSettings({
state,
capabilities,
showControls,
menus,
commands: () => commands,
reveal,
});
});
it('is unavailable without any group and cannot open', () => {
expect(settings.available()).toBe(false);
settings.open('speed');
settings.toggle();
expect(menus.settingsOpen()).toBe(false);
expect(settings.isOpen()).toBe(false);
expect(reveal).not.toHaveBeenCalled();
});
it('opens on a group with a sticky reveal and toggles closed', () => {
setCapabilities({ playbackSpeed: true });
settings.open('speed');
expect(settings.isOpen()).toBe(true);
expect(settings.focusGroup()).toBe('speed');
expect(reveal).toHaveBeenCalledWith({ scheduleHide: false });
settings.toggle();
expect(settings.isOpen()).toBe(false);
expect(settings.focusGroup()).toBeNull();
});
it('is hidden with the controls even while the menu flag is set', () => {
setCapabilities({ playbackSpeed: true });
settings.open();
showControls.set(false);
expect(settings.available()).toBe(false);
expect(settings.isOpen()).toBe(false);
});
it('derives on/modified state and the tune dots', () => {
setCapabilities({
audioTracks: true,
subtitles: true,
playbackSpeed: true,
aspectRatio: true,
qualityLevels: true,
});
setState({
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
subtitleTracks: [{ id: 5, label: 'Russian', selected: false }],
qualityLevels: [
{ id: 0, label: '1080p', selected: false },
{ id: 1, label: '720p', selected: false },
],
qualityAutoEnabled: true,
});
expect(settings.dots()).toEqual({ cyan: false, violet: false });
expect(settings.hasDots()).toBe(false);
expect(settings.subtitleLabel()).toBeNull();
expect(settings.speedLabel()).toBe('1×');
setState({
audioTracks: [
{ id: 1, label: 'English', selected: false },
{ id: 2, label: 'German', selected: true },
],
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
qualityLevels: [
{ id: 0, label: '1080p', selected: true },
{ id: 1, label: '720p', selected: false },
],
qualityAutoEnabled: false,
playbackSpeed: 1.25,
aspectRatio: '16:9',
});
expect(settings.subtitlesOn()).toBe(true);
expect(settings.subtitleLabel()).toBe('Russian');
expect(settings.audioModified()).toBe(true);
expect(settings.qualityModified()).toBe(true);
expect(settings.speedModified()).toBe(true);
expect(settings.speedLabel()).toBe('1.25×');
expect(settings.aspectModified()).toBe(true);
expect(settings.dots()).toEqual({ cyan: true, violet: true });
});
it('treats the first aspect preset as the unmodified default', () => {
setCapabilities({ aspectRatio: true });
setState({
aspectRatio: 'auto',
aspectPresets: [
{ value: 'auto', label: 'Auto' },
{ value: '16:9', label: '16:9' },
],
});
expect(settings.defaultAspect()).toBe('auto');
expect(settings.aspectModified()).toBe(false);
});
it('toggles subtitles from the chip without opening the panel', () => {
setCapabilities({ subtitles: true });
setState({
subtitleTracks: [
{ id: 7, label: 'English', selected: false },
{ id: 8, label: 'German', selected: false },
],
});
const event = { preventDefault: jest.fn() } as unknown as Event;
settings.onSubtitleChipContextMenu(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(commands.setSubtitleTrack).toHaveBeenCalledWith(7);
expect(menus.settingsOpen()).toBe(false);
setState({
subtitleTracks: [{ id: 7, label: 'English', selected: true }],
subtitlesEnabled: true,
});
settings.toggleSubtitles();
expect(commands.setSubtitleTrack).toHaveBeenLastCalledWith(-1);
});
it('opens the subtitles group instead when there is no track to turn on', () => {
setCapabilities({ externalSubtitles: true });
settings.toggleSubtitles();
expect(commands.setSubtitleTrack).not.toHaveBeenCalled();
expect(settings.isOpen()).toBe(true);
expect(settings.focusGroup()).toBe('subtitles');
});
it('ignores the subtitle toggle without a subtitle group', () => {
settings.toggleSubtitles();
expect(commands.setSubtitleTrack).not.toHaveBeenCalled();
expect(reveal).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,164 @@
import { Signal, computed } from '@angular/core';
import { speedLabel } from './controls-format.utils';
import type { ControlsMenuState } from './controls-menu-state';
import {
anySettingsGroupAvailable,
getSettingsGroupAvailability,
type SettingsGroup,
} from './controls-settings-groups';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
} from './player-controls.model';
export interface ControlsSettingsDeps {
state: Signal<PlayerControlsState>;
capabilities: Signal<PlayerControlsCapabilities>;
showControls: Signal<boolean>;
menus: ControlsMenuState;
commands: () => PlayerControlsCommands;
reveal: (options?: { scheduleHide?: boolean }) => void;
}
/** Colored state dots on the compact `tune` button. */
export interface SettingsStateDots {
/** Something is on: subtitles enabled, a non-default audio track. */
cyan: boolean;
/** A value was changed: speed, aspect ratio, manual quality. */
violet: boolean;
}
/**
* State behind the `tune` button, its chips and the settings panel: which
* groups exist, what is on or modified (the color-as-state system), and the
* open/close/toggle transitions. The panel itself only renders.
*/
export class ControlsSettings {
constructor(private readonly deps: ControlsSettingsDeps) {}
readonly groups = computed(() =>
getSettingsGroupAvailability(
this.deps.capabilities(),
this.deps.state()
)
);
readonly available = computed(
() =>
this.deps.showControls() && anySettingsGroupAvailable(this.groups())
);
readonly isOpen = computed(
() => this.available() && this.deps.menus.settingsOpen()
);
readonly focusGroup = this.deps.menus.settingsFocus;
readonly subtitlesOn = computed(
() => this.groups().subtitles && this.deps.state().subtitlesEnabled
);
/** The selected subtitle track's label, for the chip; null while off. */
readonly subtitleLabel = computed(() => {
if (!this.subtitlesOn()) {
return null;
}
return (
this.deps.state().subtitleTracks.find((track) => track.selected)
?.label ?? null
);
});
/** A track other than the first (the engine's default) is selected. */
readonly audioModified = computed(() => {
if (!this.groups().audio) {
return false;
}
const tracks = this.deps.state().audioTracks;
const selected = tracks.findIndex((track) => track.selected);
return selected > 0;
});
readonly qualityModified = computed(
() => this.groups().quality && !this.deps.state().qualityAutoEnabled
);
readonly speedModified = computed(
() => this.groups().speed && this.deps.state().playbackSpeed !== 1
);
readonly speedLabel = computed(() =>
speedLabel(this.deps.state().playbackSpeed)
);
readonly defaultAspect = computed(
() => this.deps.state().aspectPresets[0]?.value ?? 'no'
);
readonly aspectModified = computed(
() =>
this.groups().aspect &&
this.deps.state().aspectRatio !== this.defaultAspect()
);
readonly dots = computed<SettingsStateDots>(() => ({
cyan: this.subtitlesOn() || this.audioModified(),
violet:
this.speedModified() ||
this.aspectModified() ||
this.qualityModified(),
}));
readonly hasDots = computed(() => this.dots().cyan || this.dots().violet);
open(group: SettingsGroup | null = null): void {
if (!this.available()) {
return;
}
this.deps.reveal({ scheduleHide: false });
this.deps.menus.openSettings(group);
}
toggle(): void {
if (!this.available()) {
return;
}
this.deps.reveal();
this.deps.menus.toggle('settings');
}
close(): void {
this.deps.menus.close('settings');
this.deps.reveal();
}
/**
* Secondary action of the subtitle chip (right-click, long-press):
* flip subtitles without opening the panel. With no embedded track to
* turn on, the panel opens instead so the file loader is reachable.
*/
onSubtitleChipContextMenu(event: Event): void {
event.preventDefault();
this.toggleSubtitles();
}
toggleSubtitles(): void {
if (!this.groups().subtitles) {
return;
}
const state = this.deps.state();
if (state.subtitlesEnabled) {
this.deps.reveal();
this.deps.commands().setSubtitleTrack(-1);
return;
}
const first = state.subtitleTracks[0];
if (!first) {
this.open('subtitles');
return;
}
this.deps.reveal();
this.deps.commands().setSubtitleTrack(first.id);
}
}
@@ -236,8 +236,8 @@ describe('PlayerControlsComponent capability contract', () => {
],
});
fixture.detectChanges();
component.toggleMenu('audio');
expect(component.menus.audioOpen()).toBe(true);
component.settings.open('audio');
expect(component.menus.settingsOpen()).toBe(true);
fake.state.set({
...createEmptyControlsState(),
@@ -251,8 +251,8 @@ describe('PlayerControlsComponent capability contract', () => {
playbackSpeed: true,
});
fixture.detectChanges();
component.toggleMenu('speed');
expect(component.menus.speedOpen()).toBe(true);
component.settings.open('speed');
expect(component.menus.settingsOpen()).toBe(true);
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
fixture.detectChanges();
@@ -292,16 +292,16 @@ describe('PlayerControlsComponent capability contract', () => {
const root = fixture.nativeElement as HTMLElement;
const trigger = root.querySelector<HTMLButtonElement>(
'[data-test-id="player-controls-quality"]'
'[data-test-id="player-controls-settings-button"]'
);
expect(trigger).not.toBeNull();
trigger?.click();
fixture.detectChanges();
expect(component.menus.qualityOpen()).toBe(true);
expect(component.menus.settingsOpen()).toBe(true);
const entries = Array.from(
root.querySelectorAll<HTMLButtonElement>(
'.player-controls__track-popover .player-controls__track'
'[data-test-id="player-settings-quality"] .player-settings__option'
)
);
expect(entries.map((entry) => entry.textContent?.trim())).toEqual([
@@ -314,9 +314,9 @@ describe('PlayerControlsComponent capability contract', () => {
entries[2].click();
fixture.detectChanges();
expect(fake.commands.setQualityLevel).toHaveBeenCalledWith(1);
expect(component.menus.qualityOpen()).toBe(false);
// The panel stays open so the next level can be compared in place.
expect(component.menus.settingsOpen()).toBe(true);
component.toggleMenu('quality');
fake.state.set({
...createEmptyControlsState(),
qualityLevels: [{ id: 0, label: '1080p', selected: false }],
@@ -324,7 +324,9 @@ describe('PlayerControlsComponent capability contract', () => {
fixture.detectChanges();
expect(component.anyMenuOpen()).toBe(false);
expect(
root.querySelector('[data-test-id="player-controls-quality"]')
root.querySelector(
'[data-test-id="player-controls-settings-button"]'
)
).toBeNull();
});
@@ -22,6 +22,9 @@
<div
class="player-controls__title"
[class.player-controls__title--visible]="controlsAreVisible()"
[class.player-controls__title--panel-open]="
settings.isOpen() && !isCompact()
"
data-test-id="player-controls-media-title"
>
<div class="player-controls__title-primary">
@@ -39,6 +42,9 @@
<div
class="player-controls__corner"
[class.player-controls__corner--visible]="controlsAreVisible()"
[class.player-controls__corner--panel-open]="
settings.isOpen() && !isCompact()
"
(pointerenter)="chrome.onPointerEnter()"
(pointerleave)="chrome.onPointerLeave()"
(pointerdown)="chrome.onPointerDown()"
@@ -116,6 +122,12 @@
class="player-controls__bar"
[class.player-controls__bar--visible]="controlsAreVisible()"
[class.player-controls__bar--compact]="isCompact()"
[class.player-controls__bar--panel-open]="
settings.isOpen() && !isCompact()
"
[class.player-controls__bar--sheet-open]="
settings.isOpen() && isCompact()
"
(pointerenter)="chrome.onPointerEnter()"
(pointerleave)="chrome.onPointerLeave()"
(pointerdown)="chrome.onPointerDown()"
@@ -442,78 +454,21 @@
</div>
<div class="player-controls__actions">
@if (hasAudioTracks()) {
<div class="player-controls__popover-anchor">
@if (
settings.available() && !isCompact() && !settings.isOpen()
) {
@if (settings.groups().subtitles) {
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="toggleMenu('audio')"
[attr.aria-expanded]="menus.audioOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
class="player-controls__chip"
[class.player-controls__chip--on]="
settings.subtitlesOn()
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
data-test-id="player-controls-subtitle-chip"
(click)="settings.open('subtitles')"
(contextmenu)="
settings.onSubtitleChipContextMenu($event)
"
matTooltipPosition="above"
>
<mat-icon>graphic_eq</mat-icon>
</button>
@if (menus.audioOpen()) {
<div
class="player-controls__panel player-controls__track-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
| translate
"
>
<div class="player-controls__menu-title">
{{
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
| translate
}}
</div>
<div class="player-controls__track-list">
@for (
track of state().audioTracks;
track track.id
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="player-controls__track"
[class.player-controls__track--selected]="
track.selected
"
(click)="
menuSelection.audioTrack(
track.id
)
"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
@if (hasSubtitleTracks()) {
<div class="player-controls__popover-anchor">
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="toggleMenu('subtitle')"
[attr.aria-expanded]="menus.subtitleOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
@@ -523,554 +478,90 @@
matTooltipPosition="above"
>
<mat-icon>{{
state().subtitlesEnabled
? 'subtitles'
: 'subtitles_off'
settings.subtitlesOn()
? 'closed_caption'
: 'closed_caption_disabled'
}}</mat-icon>
<span class="player-controls__chip-label">{{
settings.subtitleLabel() ??
('EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
| translate)
}}</span>
</button>
@if (menus.subtitleOpen()) {
<div
class="player-controls__panel player-controls__track-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
>
<div class="player-controls__menu-title">
{{
'EMBEDDED_MPV.PLAYER.SUBTITLES'
| translate
}}
</div>
<!-- Single scroll owner for the track list AND the
delay/style sections: on short players the
popover height cap must scroll everything, not
clip the sections outside the list. -->
<div class="player-controls__subtitle-body">
<div class="player-controls__track-list">
@if (
capabilities().subtitles &&
state().subtitleTracks.length > 0
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
!state().subtitlesEnabled
"
class="player-controls__track"
[class.player-controls__track--selected]="
!state().subtitlesEnabled
"
(click)="
menuSelection.subtitleTrack(
-1
)
"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
| translate
}}</span>
@if (
!state().subtitlesEnabled
) {
<mat-icon>check</mat-icon>
}
</button>
}
@for (
track of state().subtitleTracks;
track track.id
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
track.selected
"
class="player-controls__track"
[class.player-controls__track--selected]="
track.selected
"
(click)="
menuSelection.subtitleTrack(
track.id
)
"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
@if (capabilities().externalSubtitles) {
<button
type="button"
role="menuitem"
class="player-controls__track player-controls__load-subtitle"
data-test-id="player-controls-load-subtitle"
(click)="loadExternalSubtitle()"
>
<span>{{
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE'
| translate
}}</span>
<mat-icon>upload_file</mat-icon>
</button>
}
</div>
@if (capabilities().subtitleDelay) {
<div
class="player-controls__subtitle-section"
data-test-id="player-controls-subtitle-delay"
>
<div
class="player-controls__subtitle-section-title"
>
{{
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY'
| translate
}}
</div>
<div
class="player-controls__subtitle-delay-row"
>
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="
subtitleSettings.adjustDelay(
-subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
>
<mat-icon>remove</mat-icon>
</button>
<span
class="player-controls__subtitle-delay-value"
aria-live="polite"
>{{
subtitleDelayLabel(
state()
.subtitleDelaySeconds
)
}}</span
>
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="
subtitleSettings.adjustDelay(
subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
>
<mat-icon>add</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="player-controls__button"
[disabled]="
state()
.subtitleDelaySeconds ===
0
"
(click)="
subtitleSettings.resetDelay()
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
>
<mat-icon
>restart_alt</mat-icon
>
</button>
</div>
</div>
}
@if (capabilities().subtitleStyle) {
<div
class="player-controls__subtitle-section"
data-test-id="player-controls-subtitle-style"
>
<div
class="player-controls__subtitle-section-title"
>
{{
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE'
| translate
}}
</div>
<div
class="player-controls__subtitle-preset-row"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE'
| translate
"
>
@for (
preset of subtitleSizePresets;
track preset.value
) {
<button
type="button"
role="radio"
class="player-controls__subtitle-chip"
[attr.aria-checked]="
preset.value ===
state()
.subtitleStyle
.sizePercent
"
[class.player-controls__subtitle-chip--selected]="
preset.value ===
state()
.subtitleStyle
.sizePercent
"
(click)="
subtitleSettings.setSize(
preset.value
)
"
>
{{ preset.label }}
</button>
}
</div>
<div
class="player-controls__subtitle-section-title"
>
{{
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR'
| translate
}}
</div>
<div
class="player-controls__subtitle-preset-row"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR'
| translate
"
>
@for (
preset of subtitleColorPresets;
track preset.label
) {
<button
type="button"
role="radio"
class="player-controls__subtitle-swatch"
[class.player-controls__subtitle-swatch--default]="
preset.value ===
null
"
[style.--swatch-color]="
preset.value
"
[attr.aria-checked]="
preset.value ===
state()
.subtitleStyle
.color
"
[class.player-controls__subtitle-swatch--selected]="
preset.value ===
state()
.subtitleStyle
.color
"
[attr.aria-label]="
preset.label
| translate
"
[matTooltip]="
preset.label
| translate
"
(click)="
subtitleSettings.setColor(
preset.value
)
"
></button>
}
</div>
</div>
}
</div>
</div>
}
</div>
}
@if (hasQualityLevels()) {
<div class="player-controls__popover-anchor">
}
@if (settings.groups().speed) {
<button
mat-icon-button
type="button"
class="player-controls__button"
data-test-id="player-controls-quality"
(click)="toggleMenu('quality')"
[attr.aria-expanded]="menus.qualityOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.QUALITY' | translate
class="player-controls__chip"
[class.player-controls__chip--modified]="
settings.speedModified()
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.QUALITY' | translate
"
matTooltipPosition="above"
>
<mat-icon>high_quality</mat-icon>
</button>
@if (menus.qualityOpen()) {
<div
class="player-controls__panel player-controls__track-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.QUALITY' | translate
"
>
<div class="player-controls__menu-title">
{{
'EMBEDDED_MPV.PLAYER.QUALITY'
| translate
}}
</div>
<div class="player-controls__track-list">
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
state().qualityAutoEnabled
"
class="player-controls__track"
[class.player-controls__track--selected]="
state().qualityAutoEnabled
"
(click)="menuSelection.qualityLevel(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.QUALITY_AUTO'
| translate
}}</span>
@if (state().qualityAutoEnabled) {
<mat-icon>check</mat-icon>
}
</button>
@for (
level of state().qualityLevels;
track level.id
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="level.selected"
class="player-controls__track"
[class.player-controls__track--selected]="
level.selected
"
(click)="
menuSelection.qualityLevel(
level.id
)
"
>
<span>{{ level.label }}</span>
@if (level.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
@if (capabilities().playbackSpeed) {
<div class="player-controls__popover-anchor">
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="toggleMenu('speed')"
[attr.aria-expanded]="menus.speedOpen()"
data-test-id="player-controls-speed-chip"
(click)="settings.open('speed')"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP'
| translate
: {
speed: speedLabel(
state().playbackSpeed
),
}
: { speed: settings.speedLabel() }
"
matTooltipPosition="above"
>
<mat-icon>speed</mat-icon>
<span class="player-controls__chip-label">{{
settings.speedLabel()
}}</span>
</button>
@if (menus.speedOpen()) {
<div
class="player-controls__panel player-controls__track-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate
"
>
<div class="player-controls__menu-title">
{{
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate
}}
</div>
<div class="player-controls__track-list">
@for (
preset of state().speedPresets;
track preset.value
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value ===
state().playbackSpeed
"
class="player-controls__track"
[class.player-controls__track--selected]="
preset.value ===
state().playbackSpeed
"
(click)="
menuSelection.speed(
preset.value
)
"
>
<span>{{ preset.label }}</span>
@if (
preset.value ===
state().playbackSpeed
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
}
@if (capabilities().aspectRatio) {
<div class="player-controls__popover-anchor">
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="toggleMenu('aspect')"
[attr.aria-expanded]="menus.aspectOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
"
matTooltipPosition="above"
>
<mat-icon>aspect_ratio</mat-icon>
</button>
@if (menus.aspectOpen()) {
<div
class="player-controls__panel player-controls__track-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate
"
@if (settings.available()) {
<button
mat-icon-button
type="button"
class="player-controls__button player-controls__tune"
[class.player-controls__tune--active]="
settings.isOpen()
"
data-test-id="player-controls-settings-button"
(click)="settings.toggle()"
[attr.aria-expanded]="settings.isOpen()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SETTINGS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SETTINGS' | translate
"
matTooltipPosition="above"
>
<mat-icon>tune</mat-icon>
@if (
isCompact() &&
!settings.isOpen() &&
settings.hasDots()
) {
<span
class="player-controls__tune-dots"
data-test-id="player-controls-settings-dots"
aria-hidden="true"
>
<div class="player-controls__menu-title">
{{
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate
}}
</div>
<div class="player-controls__track-list">
@for (
preset of state().aspectPresets;
track preset.value
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value ===
state().aspectRatio
"
class="player-controls__track"
[class.player-controls__track--selected]="
preset.value ===
state().aspectRatio
"
(click)="
menuSelection.aspect(
preset.value
)
"
>
<span>{{
preset.value === 'no'
? ('EMBEDDED_MPV.PLAYER.ASPECT_DEFAULT'
| translate)
: preset.label
}}</span>
@if (
preset.value ===
state().aspectRatio
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
@if (settings.dots().cyan) {
<span
class="player-controls__tune-dot player-controls__tune-dot--cyan"
></span>
}
@if (settings.dots().violet) {
<span
class="player-controls__tune-dot player-controls__tune-dot--violet"
></span>
}
</span>
}
</div>
</button>
}
@if (canRecord()) {
@if (canRecord() && !(settings.isOpen() && !isCompact())) {
<button
mat-icon-button
type="button"
@@ -1103,7 +594,10 @@
</button>
}
@if (capabilities().pictureInPicture) {
@if (
capabilities().pictureInPicture &&
!(settings.isOpen() && !isCompact())
) {
<button
mat-icon-button
type="button"
@@ -1162,4 +656,26 @@
</div>
</div>
</div>
@if (settings.isOpen()) {
<app-player-settings-panel
class="player-controls__settings"
[class.player-controls__settings--sheet]="isCompact()"
data-test-id="player-controls-settings-panel"
[controller]="controller()"
[settings]="settings"
[selection]="menuSelection"
[subtitleSettings]="subtitleSettings"
[mode]="isCompact() ? 'sheet' : 'panel'"
[title]="'EMBEDDED_MPV.PLAYER.SETTINGS' | translate"
(closeRequested)="settings.close()"
(pointerenter)="chrome.onPointerEnter()"
(pointerleave)="chrome.onPointerLeave()"
(pointerdown)="chrome.onPointerDown()"
(click)="chrome.onClick($event)"
(keydown)="chrome.onKeyDown()"
(focusin)="chrome.onFocusIn($event)"
(focusout)="chrome.onFocusOut($event)"
/>
}
}
@@ -144,7 +144,9 @@ describe('PlayerControlsComponent interactions', () => {
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95);
expect(component.feedback.current()?.label).toBe('95%');
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: -100 }));
component.volumeInteractions.wheel(
new WheelEvent('wheel', { deltaY: -100 })
);
expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1);
});
@@ -170,7 +172,9 @@ describe('PlayerControlsComponent interactions', () => {
});
it('keeps optimistic volume across capability and visibility changes', () => {
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
component.volumeInteractions.wheel(
new WheelEvent('wheel', { deltaY: 100 })
);
expect(component.displayVolume()).toBe(0.95);
setState({ positionSeconds: 15, volume: 1 });
@@ -187,7 +191,9 @@ describe('PlayerControlsComponent interactions', () => {
});
it('reapplies persisted volume when the capability returns', () => {
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
component.volumeInteractions.wheel(
new WheelEvent('wheel', { deltaY: 100 })
);
fake.commands.setVolume.mockClear();
setCapabilities({ volume: false });
@@ -202,7 +208,9 @@ describe('PlayerControlsComponent interactions', () => {
});
it('reconciles volume when the controller changes at the same value', () => {
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
component.volumeInteractions.wheel(
new WheelEvent('wheel', { deltaY: 100 })
);
expect(component.displayVolume()).toBe(0.95);
localStorage.removeItem('volume');
@@ -218,40 +226,40 @@ describe('PlayerControlsComponent interactions', () => {
});
});
describe('menu selections', () => {
it('applies a speed preset and closes the menu', () => {
describe('settings panel selections', () => {
it('applies a speed preset from the chip-opened panel and keeps it open', () => {
setCapabilities({ playbackSpeed: true });
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED"]')?.click();
query('[data-test-id="player-controls-speed-chip"]')?.click();
fixture.detectChanges();
expect(component.menus.speedOpen()).toBe(true);
expect(component.menus.settingsOpen()).toBe(true);
expect(component.menus.settingsFocus()).toBe('speed');
const preset = queryAll('.player-controls__track').find((item) =>
item.textContent?.includes('1.5×')
);
const preset = queryAll(
'[data-test-id="player-settings-speed"] .player-settings__seg-item'
).find((item) => item.textContent?.includes('1.5×'));
preset?.click();
fixture.detectChanges();
expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5);
expect(component.menus.speedOpen()).toBe(false);
expect(component.menus.settingsOpen()).toBe(true);
});
it('applies an aspect preset and closes the menu', () => {
it('applies an aspect preset from the panel', () => {
setCapabilities({ aspectRatio: true });
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.ASPECT_RATIO"]')?.click();
component.settings.open('aspect');
fixture.detectChanges();
const preset = queryAll('.player-controls__track').find((item) =>
item.textContent?.includes('16:9')
);
const preset = queryAll(
'[data-test-id="player-settings-aspect"] .player-settings__seg-item'
).find((item) => item.textContent?.includes('16:9'));
preset?.click();
fixture.detectChanges();
expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(component.menus.aspectOpen()).toBe(false);
});
it('disables subtitles via the Off entry', () => {
@@ -262,22 +270,24 @@ describe('PlayerControlsComponent interactions', () => {
});
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.SUBTITLES"]')?.click();
query('[data-test-id="player-controls-subtitle-chip"]')?.click();
fixture.detectChanges();
expect(component.menus.settingsFocus()).toBe('subtitles');
queryAll('.player-controls__track')[0]?.click();
queryAll(
'[data-test-id="player-settings-subtitles"] .player-settings__option'
)[0]?.click();
expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(-1);
expect(component.menus.subtitleOpen()).toBe(false);
});
it('opening one menu closes the others', () => {
setCapabilities({ playbackSpeed: true, aspectRatio: true });
setCapabilities({ playbackSpeed: true, volume: true });
fixture.detectChanges();
component.toggleMenu('speed');
component.toggleMenu('aspect');
expect(component.menus.speedOpen()).toBe(false);
expect(component.menus.aspectOpen()).toBe(true);
component.settings.open('speed');
component.toggleMenu('volume');
expect(component.menus.settingsOpen()).toBe(false);
expect(component.menus.volumeOpen()).toBe(true);
expect(component.anyMenuOpen()).toBe(true);
});
});
@@ -339,17 +349,25 @@ describe('PlayerControlsComponent interactions', () => {
it('keeps the controls visible while a menu is open, hides after close', () => {
setCapabilities({ playbackSpeed: true });
fixture.detectChanges();
component.toggleMenu('speed');
component.settings.open('speed');
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
// A choice keeps the panel (and the controls) on screen…
component.menuSelection.speed(1.25);
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
// …closing it re-arms the auto-hide.
component.settings.close();
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(false);
});
@@ -403,15 +421,21 @@ describe('PlayerControlsComponent interactions', () => {
});
it('opens the popover on the first tap instead of muting', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch'));
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch')
);
expect(component.menus.volumeOpen()).toBe(true);
expect(fake.commands.setVolume).not.toHaveBeenCalled();
});
it('toggles mute on a tap while the popover is open', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch'));
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch'));
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch')
);
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch')
);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
});
@@ -427,7 +451,9 @@ describe('PlayerControlsComponent interactions', () => {
});
it('mutes directly on a mouse click without opening the popover', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'mouse'));
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'mouse')
);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(component.menus.volumeOpen()).toBe(false);
@@ -450,7 +476,9 @@ describe('PlayerControlsComponent interactions', () => {
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch')
);
component.volumeInteractions.hoverLeave(new FocusEvent('focusout'));
component.volumeInteractions.hoverLeave(
new FocusEvent('focusout')
);
jest.advanceTimersByTime(1000);
expect(component.menus.volumeOpen()).toBe(true);
@@ -242,6 +242,78 @@
transform: translateY(0);
}
// The wide settings panel sits beside the video: the dock and the top chrome
// slide left so nothing is covered, in sync with the panel's own slide-in.
.player-controls__bar--panel-open {
right: 370px;
}
.player-controls__title--panel-open {
right: 354px;
}
.player-controls__corner--panel-open {
right: 374px;
}
// A compact sheet replaces the dock while open; the sheet carries the
// controls' pointer events, and the video tap that closes it lands on the
// surface underneath.
.player-controls__bar--sheet-open {
opacity: 0;
pointer-events: none;
}
// Placement of the settings surface. The panel component styles its inside.
.player-controls__settings {
position: absolute;
top: 16px;
right: 16px;
bottom: 16px;
z-index: 4;
width: 330px;
max-width: calc(100cqw - 32px);
animation: player-controls-panel-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.player-controls__settings--sheet {
top: auto;
right: 8px;
bottom: 0;
left: 8px;
width: auto;
max-width: none;
max-height: min(70%, calc(100cqh - 24px));
animation: player-controls-sheet-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes player-controls-panel-in {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes player-controls-sheet-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.player-controls__settings,
.player-controls__settings--sheet {
animation: none;
}
}
// --- Timeline row -----------------------------------------------------------
.player-controls__timeline {
@@ -462,6 +534,108 @@
color: var(--mat-sys-error, #ff5252) !important;
}
// Value chips (subtitles, speed): the two settings a viewer changes mid-film.
// Neutral until something is on (cyan) or changed (violet).
.player-controls__chip {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
height: 32px;
max-width: 160px;
padding: 0 11px;
color: var(--pc-text);
background: transparent;
border: 0;
border-radius: 10px;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 600;
transition: background 120ms ease-out;
}
.player-controls__chip mat-icon {
flex: 0 0 auto;
width: 19px;
height: 19px;
font-size: 19px;
}
.player-controls__chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-controls__chip:hover,
.player-controls__chip:focus-visible {
background: var(--pc-hover);
}
.player-controls__chip:focus-visible {
outline: 2px solid var(--pc-text);
outline-offset: 2px;
}
.player-controls__chip--on {
color: var(--pc-accent-cyan);
background: rgba(92, 214, 255, 0.15);
}
.player-controls__chip--modified {
color: var(--pc-accent-violet);
background: rgba(181, 153, 255, 0.16);
}
.player-controls__chip--on:hover,
.player-controls__chip--on:focus-visible {
background: rgba(92, 214, 255, 0.24);
}
.player-controls__chip--modified:hover,
.player-controls__chip--modified:focus-visible {
background: rgba(181, 153, 255, 0.26);
}
// The settings button: filled in the accent while its panel is open, and
// carrying state dots where the chips are folded away (compact).
:host :is(.player-controls__tune) {
position: relative;
}
:host :is(.player-controls__tune--active),
:host :is(.player-controls__tune--active:hover) {
color: #ffffff;
background: var(--pc-accent-blue);
--mdc-icon-button-icon-color: #ffffff;
--mat-icon-button-icon-color: #ffffff;
}
.player-controls__tune-dots {
position: absolute;
bottom: 3px;
left: 50%;
display: flex;
gap: 4px;
transform: translateX(-50%);
pointer-events: none;
}
.player-controls__tune-dot {
width: 5px;
height: 5px;
border-radius: 50%;
}
.player-controls__tune-dot--cyan {
background: var(--pc-accent-cyan);
}
.player-controls__tune-dot--violet {
background: var(--pc-accent-violet);
}
// Primary action: a filled circle in the accent colour. Not a Material icon
// button on purpose — the ring and the fill are the design, and Material's
// state layer would tint them.
@@ -716,25 +890,6 @@
white-space: nowrap;
}
.player-controls__track-popover {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
display: flex;
flex-direction: column;
min-width: 220px;
// Cap to the player box (minus a small gutter) so the panel never spills
// past the player's edges on small windows; falls back to the viewport
// where container units are unavailable.
max-width: min(420px, calc(100cqw - 24px), 80vw);
// Height grows to fit the list; only when it would exceed the room above
// the dock does the inner list start scrolling. 120px ≈ the dock height
// plus the 6px gap and a little breathing room.
max-height: min(calc(100cqh - 120px), 70vh);
padding: 10px 12px;
z-index: 3;
}
.player-controls__menu-title {
flex: 0 0 auto;
padding: 0 2px 6px;
@@ -798,161 +953,6 @@
font-size: 0.8rem;
}
.player-controls__track-list {
display: flex;
flex-direction: column;
gap: 4px;
// No fixed cap: the panel's max-height bounds the popover, and the title
// above stays pinned while this list takes the remaining space and scrolls
// only when the content genuinely exceeds it.
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.player-controls__track {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
color: var(--pc-text);
background: transparent;
border: 0;
border-radius: 10px;
cursor: pointer;
font: inherit;
text-align: left;
}
.player-controls__track span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-controls__track:hover,
.player-controls__track:focus-visible,
.player-controls__track--selected {
background: rgba(255, 255, 255, 0.16);
}
.player-controls__track mat-icon {
color: #ffffff;
font-size: 18px;
width: 18px;
height: 18px;
}
.player-controls__load-subtitle mat-icon {
color: rgba(255, 255, 255, 0.85);
}
// Single scroll owner for the subtitle popover's list + delay/style sections:
// on short players (e.g. the 220px portal player) the panel's max-height cap
// must scroll ALL content — sections outside the list's own scroll area would
// otherwise clip or overflow into the dock.
.player-controls__subtitle-body {
display: flex;
flex-direction: column;
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
// Inside the shared scroll owner the list must not nest a second scroll area.
.player-controls__subtitle-body .player-controls__track-list {
flex: none;
overflow-y: visible;
}
.player-controls__subtitle-section {
flex: 0 0 auto;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.player-controls__subtitle-section-title {
padding: 0 2px 6px;
color: var(--pc-text-tertiary);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.player-controls__subtitle-delay-row {
display: flex;
align-items: center;
gap: 4px;
}
.player-controls__subtitle-delay-value {
min-width: 58px;
text-align: center;
font-variant-numeric: tabular-nums;
color: var(--pc-text);
}
.player-controls__subtitle-preset-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 4px;
}
.player-controls__subtitle-chip {
padding: 4px 10px;
color: var(--pc-text);
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 999px;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
}
.player-controls__subtitle-chip:hover,
.player-controls__subtitle-chip:focus-visible,
.player-controls__subtitle-chip--selected {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.6);
}
.player-controls__subtitle-swatch {
width: 26px;
height: 26px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
cursor: pointer;
background: var(--swatch-color, transparent);
}
// The "engine default" swatch has no color of its own: render a diagonal
// slash so it reads as "no override".
.player-controls__subtitle-swatch--default {
background:
linear-gradient(
135deg,
transparent 44%,
rgba(255, 255, 255, 0.7) 46%,
rgba(255, 255, 255, 0.7) 54%,
transparent 56%
),
rgba(255, 255, 255, 0.08);
}
.player-controls__subtitle-swatch:hover,
.player-controls__subtitle-swatch:focus-visible,
.player-controls__subtitle-swatch--selected {
border-color: #ffffff;
}
// Touch scrubbing needs a taller hit strip and thumb than the 18px mouse
// slider; the drawn track is unchanged — only the input's touch area and the
// volume thumb grow.
@@ -0,0 +1,383 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { COMPACT_LAYOUT_MAX_WIDTH } from './controls-layout';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
/**
* The settings panel behind the `tune` button: chips on wide players, the
* panel beside the video, the compact bottom sheet, and the dock changes
* that come with each.
*/
describe('PlayerControlsComponent settings panel', () => {
const originalResizeObserver = globalThis.ResizeObserver;
let resizeCallbacks: ResizeCallback[];
let fixture: ComponentFixture<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
const queryAll = (selector: string) =>
Array.from(
fixture.nativeElement.querySelectorAll(
selector
) as NodeListOf<HTMLElement>
);
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const resizeTo = (width: number) => {
for (const callback of resizeCallbacks) {
callback([
{
borderBoxSize: [{ inlineSize: width, blockSize: 300 }],
contentRect: { width } as DOMRectReadOnly,
} as unknown as ResizeObserverEntry,
]);
}
fixture.detectChanges();
};
const withEverything = () => {
setCapabilities({
volume: true,
audioTracks: true,
subtitles: true,
externalSubtitles: true,
playbackSpeed: true,
aspectRatio: true,
pictureInPicture: true,
fullscreen: true,
});
setState({
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
subtitleTracks: [{ id: 5, label: 'Russian', selected: false }],
canPictureInPicture: true,
});
fixture.detectChanges();
};
beforeEach(async () => {
resizeCallbacks = [];
class FakeResizeObserver {
constructor(callback: ResizeCallback) {
resizeCallbacks.push(callback);
}
observe(): void {
/* noop */
}
unobserve(): void {
/* noop */
}
disconnect(): void {
/* noop */
}
}
globalThis.ResizeObserver =
FakeResizeObserver as unknown as typeof ResizeObserver;
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
SETTINGS: 'Settings',
CLOSE_SETTINGS: 'Close settings',
SUBTITLES: 'Subtitles',
SUBTITLES_OFF: 'Off',
PLAYBACK_SPEED: 'Playback speed',
AUDIO_TRACKS: 'Audio tracks',
ASPECT_RATIO: 'Aspect ratio',
ASPECT_DEFAULT: 'Default',
ENTER_PICTURE_IN_PICTURE: 'Enter picture-in-picture',
ENTER_FULLSCREEN: 'Enter fullscreen',
},
},
});
translate.use('en');
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('controller', fake.controller);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
globalThis.ResizeObserver = originalResizeObserver;
});
describe('wide dock', () => {
beforeEach(withEverything);
it('shows subtitle and speed chips with their values and state colors', () => {
const subtitles = query(
'[data-test-id="player-controls-subtitle-chip"]'
);
const speed = query('[data-test-id="player-controls-speed-chip"]');
expect(subtitles?.textContent).toContain('Off');
expect(subtitles?.classList).not.toContain(
'player-controls__chip--on'
);
expect(speed?.textContent).toContain('1×');
expect(speed?.classList).not.toContain(
'player-controls__chip--modified'
);
setState({
audioTracks: fake.state().audioTracks,
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
playbackSpeed: 1.25,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
?.textContent
).toContain('Russian');
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
?.classList
).toContain('player-controls__chip--on');
expect(
query('[data-test-id="player-controls-speed-chip"]')
?.textContent
).toContain('1.25×');
expect(
query('[data-test-id="player-controls-speed-chip"]')?.classList
).toContain('player-controls__chip--modified');
});
it('opens the panel on the chip group, shifts the dock and folds the chips away', () => {
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
query('[data-test-id="player-controls-speed-chip"]')?.click();
fixture.detectChanges();
const panel = query(
'[data-test-id="player-controls-settings-panel"]'
);
expect(panel).not.toBeNull();
expect(panel?.classList).not.toContain(
'player-controls__settings--sheet'
);
expect(panel?.getAttribute('role')).toBe('dialog');
expect(panel?.getAttribute('aria-label')).toBe('Settings');
expect(
query('[data-test-id="player-settings-speed"]')?.classList
).toContain('player-settings__group--focused');
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--panel-open'
);
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
expect(query('[aria-label="Enter picture-in-picture"]')).toBeNull();
expect(query('[aria-label="Enter fullscreen"]')).not.toBeNull();
expect(
query('[data-test-id="player-controls-settings-button"]')
?.classList
).toContain('player-controls__tune--active');
});
it('renders every available group in order and applies choices in place', () => {
component.settings.open();
fixture.detectChanges();
expect(
queryAll('.player-settings__group').map((group) =>
group.getAttribute('data-group')
)
).toEqual(['audio', 'subtitles', 'speed', 'aspect']);
queryAll(
'[data-test-id="player-settings-audio"] .player-settings__option'
)[1]?.click();
expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2);
const preset = queryAll(
'[data-test-id="player-settings-aspect"] .player-settings__seg-item'
).find((item) => item.textContent?.includes('16:9'));
preset?.click();
expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(component.settings.isOpen()).toBe(true);
});
it('closes from the panel button and from the tune button', () => {
component.settings.open();
fixture.detectChanges();
query('[data-test-id="player-settings-close"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--panel-open'
);
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).not.toBeNull();
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
});
it('toggles subtitles from the chip context menu without opening the panel', () => {
const chip = query(
'[data-test-id="player-controls-subtitle-chip"]'
) as HTMLElement;
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
chip.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(5);
expect(component.settings.isOpen()).toBe(false);
});
it('closes the panel once no group is left', () => {
component.settings.open('audio');
fixture.detectChanges();
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
fixture.detectChanges();
expect(component.settings.isOpen()).toBe(false);
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
});
});
describe('compact dock', () => {
beforeEach(() => {
withEverything();
resizeTo(COMPACT_LAYOUT_MAX_WIDTH);
});
it('folds the chips into the tune button with state dots', () => {
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-settings-dots"]')
).toBeNull();
setState({
audioTracks: fake.state().audioTracks,
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
playbackSpeed: 2,
});
fixture.detectChanges();
const dots = query(
'[data-test-id="player-controls-settings-dots"]'
);
expect(
dots?.querySelector('.player-controls__tune-dot--cyan')
).not.toBeNull();
expect(
dots?.querySelector('.player-controls__tune-dot--violet')
).not.toBeNull();
});
it('opens a bottom sheet that replaces the dock and keeps PiP reachable after close', () => {
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
const sheet = query(
'[data-test-id="player-controls-settings-panel"]'
);
expect(sheet?.classList).toContain(
'player-controls__settings--sheet'
);
expect(
sheet?.querySelector('.player-settings__grip')
).not.toBeNull();
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--sheet-open'
);
expect(
query('[data-test-id="player-controls-settings-dots"]')
).toBeNull();
query('[data-test-id="player-settings-close"]')?.click();
fixture.detectChanges();
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--sheet-open'
);
expect(
query('[aria-label="Enter picture-in-picture"]')
).not.toBeNull();
});
});
});
@@ -77,6 +77,7 @@ describe('PlayerControlsComponent', () => {
FORWARD_10_SECONDS: 'Forward 10 seconds',
LIVE_STREAM: 'Live stream',
PLAYBACK_POSITION: 'Playback position',
SETTINGS: 'Settings',
MUTE: 'Mute',
UNMUTE: 'Unmute',
VOLUME: 'Volume',
@@ -107,10 +108,15 @@ describe('PlayerControlsComponent', () => {
describe('capability gating', () => {
it('hides every optional control when no capability is enabled', () => {
expect(query('[aria-label="Back 10 seconds"]')).toBeNull();
expect(query('[aria-label="Audio tracks"]')).toBeNull();
expect(query('[aria-label="Subtitles"]')).toBeNull();
expect(query('[aria-label="Playback speed"]')).toBeNull();
expect(query('[aria-label="Aspect ratio"]')).toBeNull();
expect(
query('[data-test-id="player-controls-settings-button"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(query('.player-controls__record-button')).toBeNull();
expect(query('[aria-label="Enter fullscreen"]')).toBeNull();
expect(query('[aria-label="Mute"]')).toBeNull();
@@ -137,8 +143,16 @@ describe('PlayerControlsComponent', () => {
expect(query('.player-controls__record-button')).toBeNull();
});
it('shows the audio menu only with the capability and more than one track', () => {
it('shows the settings button only with the capability and more than one audio track', () => {
setCapabilities({ audioTracks: true });
setState({
audioTracks: [{ id: 1, label: 'English', selected: true }],
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-button"]')
).toBeNull();
setState({
audioTracks: [
{ id: 1, label: 'English', selected: true },
@@ -146,14 +160,23 @@ describe('PlayerControlsComponent', () => {
],
});
fixture.detectChanges();
expect(query('[aria-label="Audio tracks"]')).not.toBeNull();
expect(
query('[data-test-id="player-controls-settings-button"]')
).not.toBeNull();
expect(query('[aria-label="Settings"]')).not.toBeNull();
});
it('shows speed and aspect controls per their capabilities', () => {
it('shows the speed chip and the settings button per their capabilities', () => {
setCapabilities({ playbackSpeed: true, aspectRatio: true });
fixture.detectChanges();
expect(query('[aria-label="Playback speed"]')).not.toBeNull();
expect(query('[aria-label="Aspect ratio"]')).not.toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).not.toBeNull();
// Aspect ratio has no chip of its own: it lives in the panel.
expect(query('[aria-label="Aspect ratio"]')).toBeNull();
expect(
query('[data-test-id="player-controls-settings-button"]')
).not.toBeNull();
});
it('shows the fullscreen button when supported', () => {
@@ -201,10 +224,10 @@ describe('PlayerControlsComponent', () => {
});
fixture.detectChanges();
query('[aria-label="Audio tracks"]')?.click();
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll(
'.player-controls__track'
'[data-test-id="player-settings-audio"] .player-settings__option'
) as NodeListOf<HTMLElement>;
items[1].click();
expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2);
@@ -60,7 +60,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
const openSubtitleMenu = () => {
fixture.componentInstance.toggleMenu('subtitle');
fixture.componentInstance.settings.open('subtitles');
fixture.detectChanges();
};
@@ -97,7 +97,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
fixture.detectChanges();
});
it('renders the subtitle button with zero tracks when external loading exists', () => {
it('renders the subtitle chip with zero tracks when external loading exists', () => {
expect(query('[aria-label="Subtitles"]')).toBeNull();
setCapabilities({ externalSubtitles: true });
@@ -106,17 +106,17 @@ describe('PlayerControlsComponent subtitle settings', () => {
expect(query('[aria-label="Subtitles"]')).not.toBeNull();
openSubtitleMenu();
// No track list entries: no Off row without a selectable track…
expect(query('.player-controls__track--selected')).toBeNull();
expect(query('.player-settings__option--selected')).toBeNull();
// …but the load action is present.
const load = query('[data-test-id="player-controls-load-subtitle"]');
expect(load).not.toBeNull();
load?.click();
expect(fake.commands.addExternalSubtitleFile).toHaveBeenCalledTimes(1);
fixture.detectChanges();
// The pick closes the popover (a file dialog opens on top).
// The panel stays open: the loaded file appears as a track in it.
expect(
query('[data-test-id="player-controls-load-subtitle"]')
).toBeNull();
).not.toBeNull();
});
it('hides the load action, delay, and style sections without the capabilities', () => {
@@ -153,8 +153,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
);
expect(section).not.toBeNull();
expect(
section?.querySelector('.player-controls__subtitle-delay-value')
?.textContent
section?.querySelector('.player-settings__delay-value')?.textContent
).toContain('+0.5 s');
(
@@ -198,7 +197,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
) as HTMLElement;
const chips = Array.from(
style.querySelectorAll<HTMLButtonElement>(
'.player-controls__subtitle-chip'
'.player-settings__seg-item'
)
);
const largeChip = chips.find((chip) =>
@@ -211,9 +210,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
});
(
style.querySelector(
'[aria-label="Yellow"]'
) as HTMLButtonElement
style.querySelector('[aria-label="Yellow"]') as HTMLButtonElement
).click();
expect(fake.commands.setSubtitleStyle).toHaveBeenLastCalledWith({
sizePercent: 100,
@@ -223,7 +220,13 @@ describe('PlayerControlsComponent subtitle settings', () => {
it('guards the new commands behind their capabilities', () => {
const component = fixture.componentInstance;
component.loadExternalSubtitle();
// Without a subtitle group the panel cannot open, and without the
// capability the load action is not rendered inside it.
component.settings.open('subtitles');
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-load-subtitle"]')
).toBeNull();
component.subtitleSettings.adjustDelay(0.5);
component.subtitleSettings.resetDelay();
component.subtitleSettings.setSize(150);
@@ -205,7 +205,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
it('a click while a menu is open dismisses it instead of pausing', () => {
jest.useFakeTimers();
component.toggleMenu('speed');
component.toggleMenu('settings');
expect(component.anyMenuOpen()).toBe(true);
surface.dispatchEvent(new MouseEvent('click', { bubbles: true }));
@@ -216,7 +216,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
});
it('an outside pointerdown closes open menus', () => {
component.toggleMenu('audio');
component.toggleMenu('settings');
expect(component.anyMenuOpen()).toBe(true);
document.body.dispatchEvent(
@@ -230,7 +230,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
const popoverChild = document.createElement('button');
controlsRoot.appendChild(popoverChild);
document.body.appendChild(controlsRoot);
component.toggleMenu('audio');
component.toggleMenu('settings');
popoverChild.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
@@ -352,7 +352,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
);
it('Escape closes an open menu', () => {
component.toggleMenu('subtitle');
component.toggleMenu('settings');
expect(component.anyMenuOpen()).toBe(true);
pressKey('Escape');
@@ -20,6 +20,7 @@ import { ControlsFullscreen } from './controls-fullscreen';
import { ControlsLayout } from './controls-layout';
import { ControlsMenuSelection } from './controls-menu-selection';
import { type ControlsMenu, ControlsMenuState } from './controls-menu-state';
import { ControlsSettings } from './controls-settings';
import { ControlsShortcuts } from './controls-shortcuts';
import { ControlsStreamStats } from './controls-stream-stats';
import { ControlsSurface } from './controls-surface';
@@ -30,27 +31,24 @@ import { createControlsViewModel } from './controls-view-model';
import { ControlsVolume } from './controls-volume';
import { ControlsVolumeInteractions } from './controls-volume-interactions';
import { ControlsSubtitleSettings } from './controls-subtitle-settings';
import {
formatRemainingTime,
formatTime,
speedLabel,
} from './controls-format.utils';
import { formatRemainingTime, formatTime } from './controls-format.utils';
import type {
PlayerController,
PlayerMediaTitle,
} from './player-controls.model';
import {
SUBTITLE_COLOR_PRESETS,
SUBTITLE_DELAY_STEP_SECONDS,
SUBTITLE_SIZE_PRESETS,
subtitleDelayLabel,
} from './subtitle-style';
import { PlayerSettingsPanelComponent } from './player-settings-panel.component';
@Component({
selector: 'app-player-controls',
templateUrl: './player-controls.component.html',
styleUrl: './player-controls.component.scss',
imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe],
imports: [
MatButtonModule,
MatIconModule,
MatTooltipModule,
TranslatePipe,
PlayerSettingsPanelComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-controls-host',
@@ -114,8 +112,6 @@ export class PlayerControlsComponent implements OnDestroy {
});
readonly menuSelection = new ControlsMenuSelection({
commands: () => this.controller().commands,
menus: this.menus,
visibility: this.visibility,
revealSticky: () => this.reveal({ scheduleHide: false }),
});
readonly volumeInteractions = new ControlsVolumeInteractions({
@@ -137,6 +133,14 @@ export class PlayerControlsComponent implements OnDestroy {
readonly state = computed(() => this.controller().state());
readonly capabilities = computed(() => this.controller().capabilities());
readonly settings = new ControlsSettings({
state: this.state,
capabilities: this.capabilities,
showControls: this.showControls,
menus: this.menus,
commands: () => this.controller().commands,
reveal: (options) => this.reveal(options),
});
private readonly controllerVolume = computed(() => this.state().volume);
private readonly timeline = new ControlsTimeline(this.state);
readonly scrubPosition = this.timeline.scrubPosition;
@@ -312,11 +316,6 @@ export class PlayerControlsComponent implements OnDestroy {
this.streamStats.dispose();
}
formatTime = formatTime;
speedLabel = speedLabel;
subtitleDelayLabel = subtitleDelayLabel;
readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS;
readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS;
readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS;
togglePlay(): void {
this.reveal();
if (!this.canTogglePlay()) {
@@ -371,12 +370,6 @@ export class PlayerControlsComponent implements OnDestroy {
this.reveal();
}
loadExternalSubtitle(): void {
if (!this.capabilities().externalSubtitles) {
return;
}
this.menuSelection.externalSubtitle();
}
toggleRecording(): void {
if (!this.canRecord()) {
return;
@@ -0,0 +1,439 @@
@if (mode() === 'sheet') {
<div class="player-settings__grip" aria-hidden="true"></div>
}
<div class="player-settings__header">
<div class="player-settings__title">{{ title() }}</div>
<button
mat-icon-button
type="button"
class="player-settings__close"
data-test-id="player-settings-close"
(click)="closeRequested.emit()"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.CLOSE_SETTINGS' | translate"
[matTooltip]="'EMBEDDED_MPV.PLAYER.CLOSE_SETTINGS' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
<div class="player-settings__body">
@if (groups().audio) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('audio')"
data-group="audio"
data-test-id="player-settings-audio"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--cyan"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }}
</div>
<div
class="player-settings__options"
role="group"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
>
@for (track of state().audioTracks; track track.id) {
<button
type="button"
role="menuitemradio"
class="player-settings__option"
[class.player-settings__option--selected]="
track.selected
"
[attr.aria-checked]="track.selected"
(click)="selection().audioTrack(track.id)"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</section>
}
@if (groups().subtitles) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('subtitles')"
data-group="subtitles"
data-test-id="player-settings-subtitles"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--cyan"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }}
</div>
<div
class="player-settings__options"
role="group"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate"
>
@if (
capabilities().subtitles &&
state().subtitleTracks.length > 0
) {
<button
type="button"
role="menuitemradio"
class="player-settings__option player-settings__option--default"
[class.player-settings__option--selected]="
!state().subtitlesEnabled
"
[attr.aria-checked]="!state().subtitlesEnabled"
(click)="selection().subtitleTrack(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate
}}</span>
@if (!state().subtitlesEnabled) {
<mat-icon>check</mat-icon>
}
</button>
}
@for (track of state().subtitleTracks; track track.id) {
<button
type="button"
role="menuitemradio"
class="player-settings__option"
[class.player-settings__option--selected]="
track.selected
"
[attr.aria-checked]="track.selected"
(click)="selection().subtitleTrack(track.id)"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
@if (capabilities().externalSubtitles) {
<button
type="button"
role="menuitem"
class="player-settings__option player-settings__option--action"
data-test-id="player-controls-load-subtitle"
(click)="loadExternalSubtitle()"
>
<span>{{
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE' | translate
}}</span>
<mat-icon>upload_file</mat-icon>
</button>
}
</div>
@if (capabilities().subtitleDelay) {
<div
class="player-settings__subsection"
data-test-id="player-controls-subtitle-delay"
>
<div class="player-settings__subheading">
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }}
</div>
<div class="player-settings__delay-row">
<button
mat-icon-button
type="button"
(click)="
subtitleSettings().adjustDelay(
-subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
>
<mat-icon>remove</mat-icon>
</button>
<span
class="player-settings__delay-value"
aria-live="polite"
>{{
subtitleDelayLabel(state().subtitleDelaySeconds)
}}</span
>
<button
mat-icon-button
type="button"
(click)="
subtitleSettings().adjustDelay(
subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
>
<mat-icon>add</mat-icon>
</button>
<button
mat-icon-button
type="button"
[disabled]="state().subtitleDelaySeconds === 0"
(click)="subtitleSettings().resetDelay()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
>
<mat-icon>restart_alt</mat-icon>
</button>
</div>
</div>
}
@if (capabilities().subtitleStyle) {
<div
class="player-settings__subsection"
data-test-id="player-controls-subtitle-style"
>
<div class="player-settings__subheading">
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate }}
</div>
<div
class="player-settings__seg"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate
"
>
@for (
preset of subtitleSizePresets;
track preset.value
) {
<button
type="button"
role="radio"
class="player-settings__seg-item"
[class.player-settings__seg-item--selected]="
preset.value ===
state().subtitleStyle.sizePercent
"
[attr.aria-checked]="
preset.value ===
state().subtitleStyle.sizePercent
"
(click)="
subtitleSettings().setSize(preset.value)
"
>
{{ preset.label }}
</button>
}
</div>
<div class="player-settings__subheading">
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }}
</div>
<div
class="player-settings__swatches"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate
"
>
@for (
preset of subtitleColorPresets;
track preset.label
) {
<button
type="button"
role="radio"
class="player-settings__swatch"
[class.player-settings__swatch--default]="
preset.value === null
"
[class.player-settings__swatch--selected]="
preset.value === state().subtitleStyle.color
"
[style.--swatch-color]="preset.value"
[attr.aria-checked]="
preset.value === state().subtitleStyle.color
"
[attr.aria-label]="preset.label | translate"
[matTooltip]="preset.label | translate"
(click)="
subtitleSettings().setColor(preset.value)
"
></button>
}
</div>
</div>
}
</section>
}
@if (groups().quality) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('quality')"
data-group="quality"
data-test-id="player-settings-quality"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--violet"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }}
</div>
<div
class="player-settings__options"
role="group"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.QUALITY' | translate"
>
<button
type="button"
role="menuitemradio"
class="player-settings__option player-settings__option--default"
[class.player-settings__option--selected]="
state().qualityAutoEnabled
"
[attr.aria-checked]="state().qualityAutoEnabled"
(click)="selection().qualityLevel(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.QUALITY_AUTO' | translate
}}</span>
@if (state().qualityAutoEnabled) {
<mat-icon>check</mat-icon>
}
</button>
@for (level of state().qualityLevels; track level.id) {
<button
type="button"
role="menuitemradio"
class="player-settings__option"
[class.player-settings__option--selected]="
level.selected
"
[attr.aria-checked]="level.selected"
(click)="selection().qualityLevel(level.id)"
>
<span>{{ level.label }}</span>
@if (level.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</section>
}
@if (groups().speed) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('speed')"
data-group="speed"
data-test-id="player-settings-speed"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--violet"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }}
</div>
<div
class="player-settings__seg"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
"
>
@for (preset of state().speedPresets; track preset.value) {
<button
type="button"
role="radio"
class="player-settings__seg-item"
[class.player-settings__seg-item--default]="
preset.value === 1
"
[class.player-settings__seg-item--selected]="
preset.value === state().playbackSpeed
"
[attr.aria-checked]="
preset.value === state().playbackSpeed
"
(click)="selection().speed(preset.value)"
>
{{ preset.label }}
</button>
}
</div>
</section>
}
@if (groups().aspect) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('aspect')"
data-group="aspect"
data-test-id="player-settings-aspect"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--neutral"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }}
</div>
<div
class="player-settings__seg"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
"
>
@for (preset of state().aspectPresets; track preset.value) {
<button
type="button"
role="radio"
class="player-settings__seg-item"
[class.player-settings__seg-item--default]="
preset.value === defaultAspect()
"
[class.player-settings__seg-item--selected]="
preset.value === state().aspectRatio
"
[attr.aria-checked]="
preset.value === state().aspectRatio
"
(click)="selection().aspect(preset.value)"
>
{{
preset.value === 'no'
? ('EMBEDDED_MPV.PLAYER.ASPECT_DEFAULT'
| translate)
: preset.label
}}
</button>
}
</div>
</section>
}
</div>
@@ -0,0 +1,402 @@
// The settings panel / bottom sheet. Reads the `--pc-*` palette that the
// controls host declares; the host also positions this element, so nothing
// here knows whether it sits beside the video or rises from its bottom edge
// beyond the `--sheet` modifier's density changes.
:host {
display: flex;
flex-direction: column;
gap: 18px;
box-sizing: border-box;
padding: 18px 16px;
color: var(--pc-text, #e7ecf3);
background: var(--pc-glass-bg, rgba(12, 16, 23, 0.72));
border: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09));
border-radius: 20px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(18px) saturate(1.3);
overflow: hidden;
pointer-events: auto;
}
:host(.player-settings--sheet) {
gap: 9px;
padding: 10px 14px 12px;
border-bottom: 0;
border-radius: 16px 16px 0 0;
}
.player-settings__grip {
flex: 0 0 auto;
align-self: center;
width: 36px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.25);
}
.player-settings__header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.player-settings__title {
overflow: hidden;
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
:host(.player-settings--sheet) .player-settings__title {
font-size: 13px;
}
:host :is(button[mat-icon-button]) {
width: 32px;
height: 32px;
padding: 4px;
color: var(--pc-text, #e7ecf3);
--mdc-icon-button-icon-color: var(--pc-text, #e7ecf3);
--mat-icon-button-icon-color: var(--pc-text, #e7ecf3);
--mat-icon-button-state-layer-size: 32px;
--mdc-icon-button-state-layer-size: 32px;
--mat-icon-button-container-shape: 10px;
--mdc-icon-button-container-shape: 10px;
--mat-icon-button-icon-size: 20px;
--mdc-icon-button-icon-size: 20px;
--mat-icon-button-hover-state-layer-opacity: 0;
border-radius: 10px;
}
:host :is(button[mat-icon-button]) mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
color: var(--pc-text, #e7ecf3);
}
:host :is(button[mat-icon-button]:hover:not([disabled])) {
background: var(--pc-hover, rgba(255, 255, 255, 0.1));
}
:host :is(button[mat-icon-button][disabled]),
:host :is(button[mat-icon-button][disabled]) mat-icon {
color: var(--pc-text-tertiary, #6b7384);
--mdc-icon-button-icon-color: var(--pc-text-tertiary, #6b7384);
--mat-icon-button-icon-color: var(--pc-text-tertiary, #6b7384);
}
:host(.player-settings--sheet) .player-settings__close {
width: 26px;
height: 26px;
padding: 3px;
--mat-icon-button-state-layer-size: 26px;
--mdc-icon-button-state-layer-size: 26px;
}
// Single scroll owner: on a short player the whole content scrolls, never
// one group inside another.
.player-settings__body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 18px;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
:host(.player-settings--sheet) .player-settings__body {
gap: 9px;
}
.player-settings__group {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 6px;
border-radius: 12px;
transition: box-shadow 220ms ease-out;
}
// The group a chip opened the panel for: a brief ring, no layout change.
.player-settings__group--focused {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12);
}
.player-settings__heading {
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px 4px;
color: var(--pc-text-tertiary, #6b7384);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.player-settings__dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.player-settings__dot--cyan {
background: var(--pc-accent-cyan, #5cd6ff);
}
.player-settings__dot--violet {
background: var(--pc-accent-violet, #b599ff);
}
.player-settings__dot--neutral {
background: rgba(255, 255, 255, 0.25);
}
.player-settings__options {
display: flex;
flex-direction: column;
gap: 2px;
}
.player-settings__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 36px;
padding: 0 12px;
color: var(--pc-text, #e7ecf3);
background: transparent;
border: 0;
border-radius: 10px;
cursor: pointer;
font: inherit;
font-size: 14px;
text-align: left;
}
.player-settings__option span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-settings__option mat-icon {
flex: 0 0 auto;
width: 18px;
height: 18px;
font-size: 18px;
}
// "Off" / "Auto" rows read as the neutral choice until selected.
.player-settings__option--default {
color: var(--pc-text-secondary, #9aa3b2);
}
.player-settings__option--action {
color: var(--pc-text-secondary, #9aa3b2);
}
.player-settings__option:hover,
.player-settings__option:focus-visible {
background: var(--pc-hover, rgba(255, 255, 255, 0.1));
}
.player-settings__option--selected {
color: var(--pc-accent-cyan, #5cd6ff);
background: rgba(92, 214, 255, 0.14);
font-weight: 600;
}
.player-settings__option:focus-visible {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: -2px;
}
.player-settings__seg {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
}
.player-settings__seg-item {
flex: 1 1 auto;
height: 30px;
padding: 0 8px;
color: var(--pc-text-secondary, #9aa3b2);
background: transparent;
border: 0;
border-radius: 8px;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.player-settings__seg-item--default {
color: var(--pc-text, #e7ecf3);
}
.player-settings__seg-item:hover,
.player-settings__seg-item:focus-visible {
background: var(--pc-hover, rgba(255, 255, 255, 0.1));
}
.player-settings__seg-item--selected {
color: var(--pc-accent-violet, #b599ff);
background: rgba(181, 153, 255, 0.2);
font-weight: 650;
}
// The default value selected is "nothing changed": neutral, not violet.
.player-settings__seg-item--default.player-settings__seg-item--selected {
color: var(--pc-text, #e7ecf3);
background: rgba(255, 255, 255, 0.1);
}
.player-settings__seg-item:focus-visible {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: -2px;
}
.player-settings__subsection {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.player-settings__subheading {
padding: 0 4px;
color: var(--pc-text-tertiary, #6b7384);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.player-settings__delay-row {
display: flex;
align-items: center;
gap: 4px;
}
.player-settings__delay-value {
min-width: 58px;
color: var(--pc-text, #e7ecf3);
font-variant-numeric: tabular-nums;
text-align: center;
}
.player-settings__swatches {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 2px 4px 4px;
}
.player-settings__swatch {
width: 26px;
height: 26px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
cursor: pointer;
background: var(--swatch-color, transparent);
}
// The "engine default" swatch has no color of its own: render a diagonal
// slash so it reads as "no override".
.player-settings__swatch--default {
background:
linear-gradient(
135deg,
transparent 44%,
rgba(255, 255, 255, 0.7) 46%,
rgba(255, 255, 255, 0.7) 54%,
transparent 56%
),
rgba(255, 255, 255, 0.08);
}
.player-settings__swatch:hover,
.player-settings__swatch:focus-visible,
.player-settings__swatch--selected {
border-color: #ffffff;
}
// Sheet density: two-column rows (heading | control) so five groups fit a
// phone-height player without scrolling in the common case.
:host(.player-settings--sheet) .player-settings__group {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: 4px 10px;
align-items: start;
}
// The heading column is narrow: let "Playback speed" wrap onto two lines
// instead of running under the control.
:host(.player-settings--sheet) .player-settings__heading {
align-items: flex-start;
min-width: 0;
padding: 6px 0 0;
font-size: 10px;
line-height: 1.25;
white-space: normal;
}
:host(.player-settings--sheet) .player-settings__heading .player-settings__dot {
flex: 0 0 auto;
margin-top: 4px;
}
:host(.player-settings--sheet) .player-settings__dot {
width: 5px;
height: 5px;
}
:host(.player-settings--sheet) .player-settings__options {
flex-direction: row;
flex-wrap: wrap;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
}
:host(.player-settings--sheet) .player-settings__option {
flex: 1 1 auto;
justify-content: center;
height: 24px;
padding: 0 8px;
border-radius: 8px;
font-size: 11.5px;
}
:host(.player-settings--sheet) .player-settings__option mat-icon {
display: none;
}
:host(.player-settings--sheet) .player-settings__seg-item {
height: 24px;
font-size: 11.5px;
}
:host(.player-settings--sheet) .player-settings__subsection {
grid-column: 1 / -1;
margin-top: 2px;
padding-top: 6px;
}
@@ -0,0 +1,103 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
computed,
effect,
inject,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import type { ControlsMenuSelection } from './controls-menu-selection';
import type { ControlsSettings } from './controls-settings';
import type { SettingsGroup } from './controls-settings-groups';
import type { ControlsSubtitleSettings } from './controls-subtitle-settings';
import type { PlayerController } from './player-controls.model';
import {
SUBTITLE_COLOR_PRESETS,
SUBTITLE_DELAY_STEP_SECONDS,
SUBTITLE_SIZE_PRESETS,
subtitleDelayLabel,
} from './subtitle-style';
export type PlayerSettingsPanelMode = 'panel' | 'sheet';
/**
* The settings surface behind the dock's `tune` button: audio, subtitles
* (tracks, file loading, delay, size and color), quality, speed and aspect
* ratio in one place. Wide players show it as a right-hand panel beside the
* video, compact players as a bottom sheet. It only renders state and
* forwards choices to the collaborators the controls component owns.
*/
@Component({
selector: 'app-player-settings-panel',
templateUrl: './player-settings-panel.component.html',
styleUrl: './player-settings-panel.component.scss',
imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-settings',
role: 'dialog',
'[class.player-settings--sheet]': 'mode() === "sheet"',
'[attr.aria-label]': 'title()',
},
})
export class PlayerSettingsPanelComponent {
private readonly host: HTMLElement =
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
readonly controller = input.required<PlayerController>();
readonly settings = input.required<ControlsSettings>();
readonly selection = input.required<ControlsMenuSelection>();
readonly subtitleSettings = input.required<ControlsSubtitleSettings>();
readonly mode = input<PlayerSettingsPanelMode>('panel');
readonly title = input('');
readonly closeRequested = output<void>();
readonly state = computed(() => this.controller().state());
readonly capabilities = computed(() => this.controller().capabilities());
readonly groups = computed(() => this.settings().groups());
readonly focusGroup = computed(() => this.settings().focusGroup());
readonly defaultAspect = computed(() => this.settings().defaultAspect());
readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS;
readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS;
readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS;
readonly subtitleDelayLabel = subtitleDelayLabel;
constructor() {
// A chip click opens the panel on its group: bring that group into
// view so a long audio list cannot push the speed row off-screen.
effect(() => {
const group = this.focusGroup();
if (!group) {
return;
}
queueMicrotask(() => this.scrollGroupIntoView(group));
});
}
isFocused(group: SettingsGroup): boolean {
return this.focusGroup() === group;
}
loadExternalSubtitle(): void {
if (!this.capabilities().externalSubtitles) {
return;
}
this.selection().externalSubtitle();
}
private scrollGroupIntoView(group: SettingsGroup): void {
const element = this.host.querySelector(`[data-group="${group}"]`);
if (
element instanceof HTMLElement &&
typeof element.scrollIntoView === 'function'
) {
element.scrollIntoView({ block: 'nearest' });
}
}
}