diff --git a/.changes/playback-player-settings-panel.md b/.changes/playback-player-settings-panel.md new file mode 100644 index 000000000..0ab266108 --- /dev/null +++ b/.changes/playback-player-settings-panel.md @@ -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. diff --git a/apps/web-e2e/src/player-settings-panel.e2e.ts b/apps/web-e2e/src/player-settings-panel.e2e.ts new file mode 100644 index 000000000..26ac5bf46 --- /dev/null +++ b/apps/web-e2e/src/player-settings-panel.e2e.ts @@ -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 { + 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 { + 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 { + 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(); +}); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 00e92dc71..f84488ace 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -621,6 +621,8 @@ "SEARCH_IN": "البحث في {{title}}", "CLEAR_SEARCH": "مسح البحث", "LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…", + "SETTINGS": "الإعدادات", + "CLOSE_SETTINGS": "إغلاق الإعدادات", "SUBTITLE_DELAY": "تأخير الترجمة", "SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا", "SUBTITLE_DELAY_INCREASE": "عرض الترجمة لاحقًا", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 1b4be9931..3b05a92b8 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -621,6 +621,8 @@ "SEARCH_IN": "قلّب ف {{title}}", "CLEAR_SEARCH": "مسح البحث", "LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…", + "SETTINGS": "الإعدادات", + "CLOSE_SETTINGS": "سد الإعدادات", "SUBTITLE_DELAY": "تأخير الترجمة", "SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل", "SUBTITLE_DELAY_INCREASE": "ورّي الترجمة من بعد", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 36d299200..238500bdb 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -621,6 +621,8 @@ "SEARCH_IN": "Пошук у {{title}}", "CLEAR_SEARCH": "Ачысціць пошук", "LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…", + "SETTINGS": "Налады", + "CLOSE_SETTINGS": "Закрыць налады", "SUBTITLE_DELAY": "Затрымка субтытраў", "SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней", "SUBTITLE_DELAY_INCREASE": "Паказваць субтытры пазней", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f60cf8531..f92457b60 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dbfb4cc58..2f87a8528 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -621,6 +621,8 @@ "SEARCH_IN": "Αναζήτηση σε {{title}}", "CLEAR_SEARCH": "Καθαρισμός αναζήτησης", "LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…", + "SETTINGS": "Ρυθμίσεις", + "CLOSE_SETTINGS": "Κλείσιμο ρυθμίσεων", "SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων", "SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα", "SUBTITLE_DELAY_INCREASE": "Εμφάνιση υποτίτλων αργότερα", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 6c99bdb4c..586673b50 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c0a51add3..506a87607 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4b014fc6e..47cbf885f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 8da46cb54..66ae7e57a 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 9bc6b53d7..e37825a59 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 971dd433f..bcc09f487 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -621,6 +621,8 @@ "SEARCH_IN": "{{title}} 内を検索", "CLEAR_SEARCH": "検索をクリア", "LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…", + "SETTINGS": "設定", + "CLOSE_SETTINGS": "設定を閉じる", "SUBTITLE_DELAY": "字幕の遅延", "SUBTITLE_DELAY_DECREASE": "字幕を早く表示", "SUBTITLE_DELAY_INCREASE": "字幕を遅く表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d98dc32c7..0eabc86df 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -621,6 +621,8 @@ "SEARCH_IN": "{{title}}에서 검색", "CLEAR_SEARCH": "검색 지우기", "LOAD_SUBTITLE_FILE": "자막 파일 불러오기…", + "SETTINGS": "설정", + "CLOSE_SETTINGS": "설정 닫기", "SUBTITLE_DELAY": "자막 지연", "SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시", "SUBTITLE_DELAY_INCREASE": "자막을 더 늦게 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index cb2737e25..26ee0e6ce 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 3456e2102..4a8a02c3e 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e48fde94a..2e39b24c5 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index ea575277c..7f68d309a 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -621,6 +621,8 @@ "SEARCH_IN": "Поиск в {{title}}", "CLEAR_SEARCH": "Очистить поиск", "LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…", + "SETTINGS": "Настройки", + "CLOSE_SETTINGS": "Закрыть настройки", "SUBTITLE_DELAY": "Задержка субтитров", "SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше", "SUBTITLE_DELAY_INCREASE": "Показывать субтитры позже", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 6bbcf920d..ec8477f47 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index e289f60c5..61ef9ac6e 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -621,6 +621,8 @@ "SEARCH_IN": "在 {{title}} 中搜索", "CLEAR_SEARCH": "清除搜索", "LOAD_SUBTITLE_FILE": "加载字幕文件…", + "SETTINGS": "设置", + "CLOSE_SETTINGS": "关闭设置", "SUBTITLE_DELAY": "字幕延迟", "SUBTITLE_DELAY_DECREASE": "提前显示字幕", "SUBTITLE_DELAY_INCREASE": "延后显示字幕", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ed991534f..60720e823 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -621,6 +621,8 @@ "SEARCH_IN": "在 {{title}} 中搜尋", "CLEAR_SEARCH": "清除搜尋", "LOAD_SUBTITLE_FILE": "載入字幕檔案…", + "SETTINGS": "設定", + "CLOSE_SETTINGS": "關閉設定", "SUBTITLE_DELAY": "字幕延遲", "SUBTITLE_DELAY_DECREASE": "提前顯示字幕", "SUBTITLE_DELAY_INCREASE": "延後顯示字幕", diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 626b70eec..c88934915 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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 diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts index c692ec3e4..e4fdd2488 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.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; - 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); }); }); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts index 3a57ad9cf..e18c39127 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts @@ -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(); } } diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts index 630fd7f0e..7dd432cae 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts @@ -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); + }); }); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts index 387d0df5c..6568b10ef 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts @@ -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>; @@ -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(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; } diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts b/libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts new file mode 100644 index 000000000..c60d281c7 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts @@ -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 +>; + +/** + * 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]); +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts new file mode 100644 index 000000000..6b00bf2c3 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts @@ -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; + let capabilities: WritableSignal; + let showControls: WritableSignal; + let menus: ControlsMenuState; + let commands: jest.Mocked; + let reveal: jest.Mock; + let settings: ControlsSettings; + + const setState = (overrides: Partial) => + state.set({ ...createEmptyControlsState(), ...overrides }); + const setCapabilities = (overrides: Partial) => + 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(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings.ts b/libs/ui/playback/src/lib/player-controls/controls-settings.ts new file mode 100644 index 000000000..c9987ccef --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-settings.ts @@ -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; + capabilities: Signal; + showControls: Signal; + 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(() => ({ + 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); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts index 25d69a531..3b1d4a3ab 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts @@ -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( - '[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( - '.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(); }); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index 468cde5b7..11864254c 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -22,6 +22,9 @@
@@ -39,6 +42,9 @@
- @if (hasAudioTracks()) { -
+ @if ( + settings.available() && !isCompact() && !settings.isOpen() + ) { + @if (settings.groups().subtitles) { - @if (menus.audioOpen()) { - - } -
- } - - @if (hasSubtitleTracks()) { -
- - @if (menus.subtitleOpen()) { - - } -
- } - - @if (hasQualityLevels()) { -
+ } + @if (settings.groups().speed) { - @if (menus.qualityOpen()) { - - } -
- } - - @if (capabilities().playbackSpeed) { -
- - @if (menus.speedOpen()) { - - } -
+ } } - @if (capabilities().aspectRatio) { -
- - @if (menus.aspectOpen()) { - + @if (settings.dots().cyan) { + + } + @if (settings.dots().violet) { + + } + } -
+ } - @if (canRecord()) { + @if (canRecord() && !(settings.isOpen() && !isCompact())) {
+ + @if (settings.isOpen()) { + + } } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts index 217bb33f8..20ca82b59 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts @@ -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); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index 6b2835f6c..ff940984f 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -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. diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts new file mode 100644 index 000000000..77eafa1a9 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts @@ -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 = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + 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; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + const queryAll = (selector: string) => + Array.from( + fixture.nativeElement.querySelectorAll( + selector + ) as NodeListOf + ); + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + 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(); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts index 5d31d59ee..1bd3e1856 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts @@ -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; items[1].click(); expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts index cccbd8b1b..85932ffc9 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts @@ -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( - '.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); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts index f5a18032d..a77ca701a 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts @@ -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'); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index b4a4b45df..ebea6a23a 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -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; diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html new file mode 100644 index 000000000..f58ac738b --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html @@ -0,0 +1,439 @@ +@if (mode() === 'sheet') { + +} +
+
{{ title() }}
+ +
+ +
+ @if (groups().audio) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }} +
+
+ @for (track of state().audioTracks; track track.id) { + + } +
+
+ } + + @if (groups().subtitles) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }} +
+
+ @if ( + capabilities().subtitles && + state().subtitleTracks.length > 0 + ) { + + } + @for (track of state().subtitleTracks; track track.id) { + + } + @if (capabilities().externalSubtitles) { + + } +
+ + @if (capabilities().subtitleDelay) { +
+
+ {{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }} +
+
+ + {{ + subtitleDelayLabel(state().subtitleDelaySeconds) + }} + + +
+
+ } + + @if (capabilities().subtitleStyle) { +
+
+ {{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate }} +
+
+ @for ( + preset of subtitleSizePresets; + track preset.value + ) { + + } +
+
+ {{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }} +
+
+ @for ( + preset of subtitleColorPresets; + track preset.label + ) { + + } +
+
+ } +
+ } + + @if (groups().quality) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }} +
+
+ + @for (level of state().qualityLevels; track level.id) { + + } +
+
+ } + + @if (groups().speed) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }} +
+
+ @for (preset of state().speedPresets; track preset.value) { + + } +
+
+ } + + @if (groups().aspect) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }} +
+
+ @for (preset of state().aspectPresets; track preset.value) { + + } +
+
+ } +
diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss new file mode 100644 index 000000000..55e3bf2ba --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss @@ -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; +} diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts new file mode 100644 index 000000000..ef7e72a94 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts @@ -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).nativeElement; + readonly controller = input.required(); + readonly settings = input.required(); + readonly selection = input.required(); + readonly subtitleSettings = input.required(); + readonly mode = input('panel'); + readonly title = input(''); + readonly closeRequested = output(); + + 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' }); + } + } +}