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