feat(playback): Hybrid redesign of the shared player controls (#1709)

This commit is contained in:
4gray authored and GitHub committed 2026-09-28 07:32:55 +02:00
1 parent 8b570e3390
commit be217d5cf5
87 files changed
+6152 -1670

No files matched your search

@@ -0,0 +1,10 @@
---
type: feature
area: playback
highlight: Redesigned player controls
---
The built-in player controls have a new look: a lighter dock that sits
directly on the video, a round accent play button, the volume slider inline
on wide players, a remaining-time counter, and a timeline that shows the time
under your pointer before you seek.
@@ -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,8 @@
---
type: internal
area: playback
---
The shared player timeline can draw chapter or programme segments with
per-segment progress and a titled hover label; no player supplies segments
yet, so the bar still renders as one segment.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: playback
highlight: Up next in the player
---
In the last minutes of a series episode the player shows an "Up next" card
with the following episode's still, title and time remaining. One click
plays it, and the switch keeps you in fullscreen.
@@ -699,6 +699,18 @@ test.describe('Electron Settings', () => {
await goToDashboard(app.mainWindow);
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
// The URL changes before the settings view is torn down and the
// dashboard renders. Wait for the dashboard itself, or the rail
// check passes vacuously and the "Dashboard" link below also
// matches the settings page's own "Dashboard" section link.
// Attached, not visible: with its only populated rail disabled
// the fixture's dashboard is empty and has no size.
await expect(
app.mainWindow.getByTestId('dashboard-page')
).toBeAttached({ timeout: 20000 });
await expect(
app.mainWindow.getByTestId('settings-section-dashboard')
).toHaveCount(0);
await expect(
app.mainWindow.getByTestId('dashboard-recent-sources-rail')
).toHaveCount(0);
@@ -0,0 +1,217 @@
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);
// Chips and the side panel need a player of at least 960px.
await page.setViewportSize({ width: 1600, height: 1000 });
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();
});
@@ -123,6 +123,54 @@ test.describe('@xtream inline series fullscreen', () => {
expect(await fullscreenOwner()).not.toBeNull();
});
test('shows the Up next card near the end and plays the next episode from it', async ({
page,
request,
}) => {
await routeEpisodeClip(page);
await rewriteSeriesEpisodesToMp4(page);
await selectWebPlayer(page, 'HTML5 video player');
const { playerView, video } = await playFirstSeriesEpisode(
page,
request
);
await expect(playerView.locator('app-html-video-player')).toBeVisible({
timeout: 15_000,
});
await expect
.poll(() =>
video.evaluate((el) => (el as HTMLVideoElement).readyState)
)
.toBeGreaterThanOrEqual(1);
// The 30 s fixture clip is inside the card's threshold from its
// first frame, so the card is up as soon as the duration is known.
const card = playerView.locator(
'[data-test-id="player-controls-up-next"]'
);
await expect(card).toBeVisible();
await expect(card).toContainText('Up next');
await expect(card).toHaveAttribute('aria-label', /S01E02/);
// The settings panel takes the same corner: the card yields to it.
await playerView.hover();
await playerView
.locator('[data-test-id="player-controls-settings-button"]')
.click();
await expect(card).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(card).toBeVisible();
// A click plays the next episode through the host's own path.
await card.click();
await expect(
page.locator('app-portal-inline-player .player-shell__episode-meta')
).toContainText('S01E02', { timeout: 15_000 });
await expect(card).toHaveAttribute('aria-label', /S01E03/, {
timeout: 15_000,
});
});
test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({
page,
request,
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "البحث في {{title}}",
"CLEAR_SEARCH": "مسح البحث",
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
"SETTINGS": "الإعدادات",
"UP_NEXT": "التالي",
"UP_NEXT_IN": "خلال {{minutes}} دقيقة",
"CLOSE_SETTINGS": "إغلاق الإعدادات",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا",
"SUBTITLE_DELAY_INCREASE": "عرض الترجمة لاحقًا",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "قلّب ف {{title}}",
"CLEAR_SEARCH": "مسح البحث",
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
"SETTINGS": "الإعدادات",
"UP_NEXT": "اللي جاي",
"UP_NEXT_IN": "فـ {{minutes}} دقيقة",
"CLOSE_SETTINGS": "سد الإعدادات",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل",
"SUBTITLE_DELAY_INCREASE": "ورّي الترجمة من بعد",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Пошук у {{title}}",
"CLEAR_SEARCH": "Ачысціць пошук",
"LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…",
"SETTINGS": "Налады",
"UP_NEXT": "Далей",
"UP_NEXT_IN": "праз {{minutes}} хв",
"CLOSE_SETTINGS": "Закрыць налады",
"SUBTITLE_DELAY": "Затрымка субтытраў",
"SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней",
"SUBTITLE_DELAY_INCREASE": "Паказваць субтытры пазней",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "In {{title}} suchen",
"CLEAR_SEARCH": "Suche löschen",
"LOAD_SUBTITLE_FILE": "Untertiteldatei laden…",
"SETTINGS": "Einstellungen",
"UP_NEXT": "Als Nächstes",
"UP_NEXT_IN": "in {{minutes}} Min.",
"CLOSE_SETTINGS": "Einstellungen schließen",
"SUBTITLE_DELAY": "Untertitelverzögerung",
"SUBTITLE_DELAY_DECREASE": "Untertitel früher anzeigen",
"SUBTITLE_DELAY_INCREASE": "Untertitel später anzeigen",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Αναζήτηση σε {{title}}",
"CLEAR_SEARCH": "Καθαρισμός αναζήτησης",
"LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…",
"SETTINGS": "Ρυθμίσεις",
"UP_NEXT": "Επόμενο",
"UP_NEXT_IN": "σε {{minutes}} λεπτά",
"CLOSE_SETTINGS": "Κλείσιμο ρυθμίσεων",
"SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων",
"SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα",
"SUBTITLE_DELAY_INCREASE": "Εμφάνιση υποτίτλων αργότερα",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Search in {{title}}",
"CLEAR_SEARCH": "Clear search",
"LOAD_SUBTITLE_FILE": "Load subtitle file…",
"SETTINGS": "Settings",
"UP_NEXT": "Up next",
"UP_NEXT_IN": "in {{minutes}} min",
"CLOSE_SETTINGS": "Close settings",
"SUBTITLE_DELAY": "Subtitle delay",
"SUBTITLE_DELAY_DECREASE": "Show subtitles earlier",
"SUBTITLE_DELAY_INCREASE": "Show subtitles later",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Buscar en {{title}}",
"CLEAR_SEARCH": "Borrar búsqueda",
"LOAD_SUBTITLE_FILE": "Cargar archivo de subtítulos…",
"SETTINGS": "Ajustes",
"UP_NEXT": "A continuación",
"UP_NEXT_IN": "en {{minutes}} min",
"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",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Rechercher dans {{title}}",
"CLEAR_SEARCH": "Effacer la recherche",
"LOAD_SUBTITLE_FILE": "Charger un fichier de sous-titres…",
"SETTINGS": "Réglages",
"UP_NEXT": "À suivre",
"UP_NEXT_IN": "dans {{minutes}} min",
"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",
+4
View File
@@ -649,6 +649,10 @@
"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",
"UP_NEXT": "Következő",
"UP_NEXT_IN": "{{minutes}} perc múlva",
"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",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Cerca in {{title}}",
"CLEAR_SEARCH": "Cancella ricerca",
"LOAD_SUBTITLE_FILE": "Carica file sottotitoli…",
"SETTINGS": "Impostazioni",
"UP_NEXT": "Prossimo",
"UP_NEXT_IN": "tra {{minutes}} min",
"CLOSE_SETTINGS": "Chiudi impostazioni",
"SUBTITLE_DELAY": "Ritardo sottotitoli",
"SUBTITLE_DELAY_DECREASE": "Mostra i sottotitoli prima",
"SUBTITLE_DELAY_INCREASE": "Mostra i sottotitoli dopo",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "{{title}} 内を検索",
"CLEAR_SEARCH": "検索をクリア",
"LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…",
"SETTINGS": "設定",
"UP_NEXT": "次のエピソード",
"UP_NEXT_IN": "あと{{minutes}}分",
"CLOSE_SETTINGS": "設定を閉じる",
"SUBTITLE_DELAY": "字幕の遅延",
"SUBTITLE_DELAY_DECREASE": "字幕を早く表示",
"SUBTITLE_DELAY_INCREASE": "字幕を遅く表示",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "{{title}}에서 검색",
"CLEAR_SEARCH": "검색 지우기",
"LOAD_SUBTITLE_FILE": "자막 파일 불러오기…",
"SETTINGS": "설정",
"UP_NEXT": "다음 에피소드",
"UP_NEXT_IN": "{{minutes}}분 후",
"CLOSE_SETTINGS": "설정 닫기",
"SUBTITLE_DELAY": "자막 지연",
"SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시",
"SUBTITLE_DELAY_INCREASE": "자막을 더 늦게 표시",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Zoeken in {{title}}",
"CLEAR_SEARCH": "Zoekopdracht wissen",
"LOAD_SUBTITLE_FILE": "Ondertitelbestand laden…",
"SETTINGS": "Instellingen",
"UP_NEXT": "Hierna",
"UP_NEXT_IN": "over {{minutes}} min",
"CLOSE_SETTINGS": "Instellingen sluiten",
"SUBTITLE_DELAY": "Ondertitelvertraging",
"SUBTITLE_DELAY_DECREASE": "Ondertitels eerder tonen",
"SUBTITLE_DELAY_INCREASE": "Ondertitels later tonen",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Szukaj w {{title}}",
"CLEAR_SEARCH": "Wyczyść wyszukiwanie",
"LOAD_SUBTITLE_FILE": "Wczytaj plik napisów…",
"SETTINGS": "Ustawienia",
"UP_NEXT": "Następny",
"UP_NEXT_IN": "za {{minutes}} min",
"CLOSE_SETTINGS": "Zamknij ustawienia",
"SUBTITLE_DELAY": "Opóźnienie napisów",
"SUBTITLE_DELAY_DECREASE": "Pokaż napisy wcześniej",
"SUBTITLE_DELAY_INCREASE": "Pokaż napisy później",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Pesquisar em {{title}}",
"CLEAR_SEARCH": "Limpar pesquisa",
"LOAD_SUBTITLE_FILE": "Carregar ficheiro de legendas…",
"SETTINGS": "Configurações",
"UP_NEXT": "A seguir",
"UP_NEXT_IN": "em {{minutes}} min",
"CLOSE_SETTINGS": "Fechar configurações",
"SUBTITLE_DELAY": "Atraso das legendas",
"SUBTITLE_DELAY_DECREASE": "Mostrar legendas mais cedo",
"SUBTITLE_DELAY_INCREASE": "Mostrar legendas mais tarde",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "Поиск в {{title}}",
"CLEAR_SEARCH": "Очистить поиск",
"LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…",
"SETTINGS": "Настройки",
"UP_NEXT": "Далее",
"UP_NEXT_IN": "через {{minutes}} мин",
"CLOSE_SETTINGS": "Закрыть настройки",
"SUBTITLE_DELAY": "Задержка субтитров",
"SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше",
"SUBTITLE_DELAY_INCREASE": "Показывать субтитры позже",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "{{title}} içinde ara",
"CLEAR_SEARCH": "Aramayı temizle",
"LOAD_SUBTITLE_FILE": "Altyazı dosyası yükle…",
"SETTINGS": "Ayarlar",
"UP_NEXT": "Sıradaki",
"UP_NEXT_IN": "{{minutes}} dk içinde",
"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",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "在 {{title}} 中搜索",
"CLEAR_SEARCH": "清除搜索",
"LOAD_SUBTITLE_FILE": "加载字幕文件…",
"SETTINGS": "设置",
"UP_NEXT": "接下来",
"UP_NEXT_IN": "{{minutes}} 分钟后",
"CLOSE_SETTINGS": "关闭设置",
"SUBTITLE_DELAY": "字幕延迟",
"SUBTITLE_DELAY_DECREASE": "提前显示字幕",
"SUBTITLE_DELAY_INCREASE": "延后显示字幕",
+4
View File
@@ -649,6 +649,10 @@
"SEARCH_IN": "在 {{title}} 中搜尋",
"CLEAR_SEARCH": "清除搜尋",
"LOAD_SUBTITLE_FILE": "載入字幕檔案…",
"SETTINGS": "設定",
"UP_NEXT": "接下來",
"UP_NEXT_IN": "{{minutes}} 分鐘後",
"CLOSE_SETTINGS": "關閉設定",
"SUBTITLE_DELAY": "字幕延遲",
"SUBTITLE_DELAY_DECREASE": "提前顯示字幕",
"SUBTITLE_DELAY_INCREASE": "延後顯示字幕",
+2 -1
View File
@@ -88,7 +88,8 @@ icons. The dock must never pair a dark fallback surface with inherited light
app text. Loader/stall and transient feedback overlays own a light foreground
and dark scrim because they cover video. Video viewports remain black in both
themes and fullscreen; frame-copy and built-in shared controls keep their
existing light-on-dark overlay palette.
light-on-dark overlay palette — the fixed `--pc-*` token set of the shared
dock (accent blue, cyan, violet and a light text ramp), never the app theme.
EPG timeline, list, empty states and programme details use the library-local
`libs/ui/epg/src/lib/_epg-theme.scss` palette, based on app surfaces, separators,
+231 -32
View File
@@ -287,9 +287,169 @@ It owns only transient presentation behavior:
controller state;
- `ControlsShortcuts` — document keyboard routing;
- `ControlsSurface` — pointer/click/double-click surface interactions;
- `ControlsTimeline` — scrub state and timeline projections; and
- `ControlsTimeline` — scrub state and timeline projections;
- `ControlsTimelineHover` — the time under the pointer over the timeline;
- `app-player-timeline` — presentation of the timeline row (current time,
segment track, knob, hover label, remaining time / LIVE, recording
status); scrub `input`/`change` events go back to the controls component,
which owns reveal and seeking;
- `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;
- `ControlsUpNext` and `app-player-up-next-card` — the "Up next" card's
gate and presentation; and
- `controls-view-model.ts` — derived display state.
### The dock
The controls render as a **dock** (`.player-controls__bar`) with no surface
of its own: a timeline row above a three-column control row, sitting
directly on the video over the bottom scrim. The palette is a fixed set of
`--pc-*` custom properties on `:host` — accent blue `#4f8eff` for the
primary action and progress, cyan `#5cd6ff` for "something is on", violet
`#b599ff` for "a value was changed", and the `#e7ecf3` / `#9aa3b2` /
`#6b7384` text ramp. They are literal on purpose: the overlay is
theme-independent (see the UI guidelines' player theme boundary), and the
app's `--app-selection-color` is a different blue that would fight the video.
- **Timeline row**: current time (`--pc-font-mono`, tabular) · drawn track
(`.player-controls__timeline-track` with one segment and an accent fill,
a white knob ringed in translucent blue) · remaining time as `−7:03`
(`formatRemainingTime`; the LIVE badge replaces it on live streams and
`--:--` stands in while no duration is known) · the recording status.
The `<input type="range">` stays as the interaction and accessibility
layer, invisible and full-size over the drawn track: dragging, arrow
keys, `aria-valuetext` and the focus ring (drawn on the track through
`:has(:focus-visible)`) all belong to it, so scrubbing semantics are
unchanged. Hovering the bar with a mouse shows a white marker and a
`1:40` label above the pointer (`ControlsTimelineHover`); touch never
hovers and a non-seekable timeline never labels.
- **Control row**: `minmax(0,1fr) auto minmax(0,1fr)`. Left: the volume
button, with the slider **inline** (72px) in the wide mode and behind
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 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. Fills under a white glyph (play, active
`tune`) use `--pc-accent-blue-strong` `#3474e8` (4.4:1) rather than the
`#4f8eff` accent (3.2:1), and hover darkens to `#2a66d6` (5.3:1) without
scaling — `player-theme.e2e.ts` rasterizes the hovered and focused
states, and on a 1x Windows display the antialiased or resampled glyph
measured below 3:1 against the lighter fills.
- **Icon buttons** are 40px with a 12px radius (32px / 9px compact) through
Material's `--mat-icon-button-*` tokens; their hover is a flat
`rgba(255,255,255,.1)` layer.
### Timeline segments
The track is drawn as a row of segments, one flex item per segment with
`flex-grow` equal to its share of the duration and its own accent fill, so
a film's chapters or a catch-up recording's programmes read directly off
the bar. Each segment is placed absolutely at its time position (`left` =
start percent, `width` = share minus the 3px gap every segment but the
last keeps), so a drawn boundary sits exactly where the linear seek input
and the hover label change segment; a segment shorter than the gap
collapses instead of pushing its neighbours. The optional `timelineSegments` input
(`PlayerTimelineSegment { startSeconds, endSeconds, title }`) supplies
them; `normalizeTimelineSegments` (`controls-timeline-segments.ts`) clamps
to the duration, orders, drops empty and reversed entries, cuts overlaps at
the previous end and fills every gap with an untitled segment so the row
always covers `[0, duration]`. Without segments — every host today — the
row is one untitled segment, which is the plain bar. `ControlsTimeline`
owns the normalized list and the per-segment fill for the current scrub or
playback value; the hover label becomes `Chapter 2 · 12:40` over a titled
segment. Producers (EPG programmes for catch-up and timeshift playback,
mpv's chapter list) are separate follow-ups; the rendering, model and rules
are in place for them.
### Up next card
Near the end of a series episode the dock shows an **"Up next" card**
(`app-player-up-next-card`, `data-test-id="player-controls-up-next"`) in the
bottom-right corner above the controls: the next episode's still (or its
`S01E03` label as a tile), a 3px accent progress line when it was partly
watched, "Up next · in 7 min" and the title. The host supplies the item
through the optional `upNext` input (`PlayerUpNextItem { label, title,
thumbnailUrl, progressPercent }`); `ControlsUpNext` decides when it shows —
`seriesNavigation` capability, a finite duration with at
most `UP_NEXT_THRESHOLD_SECONDS` (8 min) left, not live, not `ended` (with
autoplay off nothing is scheduled, so no countdown), controls shown,
settings panel closed — and how many minutes remain (never below one). A
still that fails to load falls back to the label tile. A
click emits `nextEpisodeRequested` directly — not through the
transport's `canNextEpisode` guard, which is season-local — so the card
also works at a season's last episode. `PortalInlinePlayerComponent`
routes such a request through the Up Next rail selection
(`upNextEpisodeSelected`) whenever `seriesNavigation.canNext` is false, which
plays the next season's first episode; either path keeps fullscreen exactly
like the transport button.
The card is a glass surface that does not fade with the controls; the
compact dock uses a smaller variant without the trailing icon.
Plumbing mirrors `mediaTitle`: `PortalInlinePlayerComponent.playerUpNext`
derives the item after the playing one from its `upNextEpisodes` input
(episodes only) → `WebPlayerViewComponent.upNext` → the four engine hosts →
`app-player-controls`. Movie and live hosts pass nothing.
### 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.
- **Roomy wide dock** (≥ 960px): 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, and wide docks below 960px**: 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). The panel is a `role="dialog"` with `tabindex="-1"`: opened from
the keyboard (the opener is `:focus-visible`) it takes focus, a pointer
open leaves focus alone (a focused control would capture Space from the
shortcuts), and closing with focus inside returns it to `tune`. While
the compact sheet replaces the dock, the dock is `inert`, so hidden
controls leave the tab order. 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
@@ -360,13 +520,18 @@ Per engine:
no info affordance, though its backends plumb the properties for parity. See
[embedded-mpv-native.md](./embedded-mpv-native.md#stream-stats-properties).
### Top scrim
### Scrims
`.player-controls__top-scrim` is a single pointer-transparent gradient at the
top of the player, mirroring the bottom bar's stops so both edges read as one
system. It renders whenever there is top chrome to back — the fullscreen media
title or the corner buttons — and fades with the controls without sliding (a
moving scrim edge is visible against video in a way a moving control is not).
top of the player (`max(28%, 112px)` tall), and
`.player-controls__bottom-scrim` its mirror behind the dock (55% tall, from
`rgba(4,7,11,.92)` at the edge through `.55` to transparent). Both read as
one system. The top one renders whenever there is top chrome to back — the
fullscreen media title or the corner buttons — the bottom one with the dock;
both fade with the controls without sliding (a moving scrim edge is visible
against video in a way a moving control is not). The dock itself has no
background: the bottom scrim is the only thing between the controls and the
picture.
One element, not a background per consumer: the title and the corner overlap,
and two gradients would darken the overlap twice. The title therefore carries
@@ -688,7 +853,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
@@ -882,24 +1048,41 @@ the last second (`wasTouchInteraction`). Three behaviors diverge from mouse:
popover close (outside taps and other menu buttons dismiss it), and neither
does the `focusout` of a pointer focus release.
- **Coarse-pointer scrub sizing.** Under `@media (pointer: coarse)` the
timeline/volume sliders grow their input hit strip to 28px and the thumb to
18px; the 4px visual track is unchanged.
timeline bar and the volume slider grow their hit strip to 28px and the
volume thumb to 16px; the drawn tracks are unchanged.
### Narrow-player layout
### Compact and wide layout
The controls host is a size query container (`player-controls`). At container
widths of 640px and below — phone-sized PWA viewports, but also small inline
players inside wide desktop windows — the single-row bar reflows to two rows:
the timeline takes a full-width first row, and the transport and actions
clusters split the second. The actions cluster's width is content-dependent
(volume, audio, subtitles, quality, speed, aspect, recording, PiP, and
fullscreen are all conditional), so in the narrow layout the cluster is
end-aligned, capped at the row width, and wraps when even a dedicated row cannot
hold it. Its popover anchors become static at this breakpoint so capability
panels position against the unclipped actions cluster and remain accessible
above every wrapped row. Icon buttons compact from 48px to 40px in this layout.
Between ~640px and the 720px viewport media query, the legacy single-row squeeze
(timeline absorbs the shrink) still applies.
The controls host is a size query container (`player-controls`), and the
dock has two modes split at **720px of container width**: `compact` at
719px and below — phone-sized PWA viewports, but also small inline players
inside wide desktop windows — and `wide` above. The split lives in two
places that must agree: the `@container player-controls (max-width: 719px)`
block in the stylesheet sizes the compact dock (14px gutters, 32px buttons,
36px play circle, 5px track), and `ControlsLayout`
(`COMPACT_LAYOUT_MAX_WIDTH`, a `ResizeObserver` on the host) drives the
template branches CSS cannot express — the inline volume slider versus
its popover. Without `ResizeObserver` (unit tests) the mode stays `wide`.
A second threshold, `ROOMY_LAYOUT_MIN_WIDTH` (960px, `ControlsLayout.roomy`),
gates the wide dock's extras: the subtitle/speed chips and the settings
panel beside the video. Between 720px and 960px the dock stays wide (full
button sizes, inline volume) but folds the chips into `tune` with state
dots and opens settings as the bottom sheet, because the widest action row
(volume, series transport, two chips, tune/record/PiP/fullscreen) and the
dock beside a 370px panel do not fit there. The control row's side columns
are `minmax(min-content, 1fr)`, so if the actions still need more than half
of what the transport leaves, the transport slides off-centre instead of
the actions overlapping it or leaving the player.
Episode navigation stays in the compact transport: the series hosts rely on
those buttons, and the inline series player is often narrower than 720px.
The actions cluster's width is content-dependent (audio, subtitles, quality,
speed, aspect, recording, PiP, and fullscreen are all conditional), so in
the compact layout the cluster is end-aligned, capped at the row width, and
wraps when the row cannot hold it. Its popover anchors become static at this
breakpoint so capability panels position against the unclipped actions
cluster and remain accessible above every wrapped row.
When a volume-capable controller first attaches, an existing `localStorage`
volume preference is applied before the first controller snapshot can reconcile
@@ -1206,11 +1389,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:
@@ -1221,11 +1404,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
@@ -1385,6 +1569,21 @@ libs/ui/playback/src/lib/player-controls/
├── player-controls.component.scss
├── controls-feedback.ts
├── controls-format.utils.ts
├── controls-layout.ts
├── controls-timeline-hover.ts
├── controls-timeline-segments.ts
├── controls-settings.ts
├── controls-settings-groups.ts
├── controls-up-next.ts
├── player-timeline.component.ts
├── player-timeline.component.html
├── player-timeline.component.scss
├── player-up-next-card.component.ts
├── player-up-next-card.component.html
├── player-up-next-card.component.scss
├── 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
@@ -12,6 +12,7 @@
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -20,6 +20,7 @@ import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -59,6 +60,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -103,6 +103,7 @@
[playerSurface]="playerSurface()"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="showControls()"
[shortcutsEnabled]="sharedShortcutsEnabled()"
(previousEpisodeRequested)="requestPreviousEpisode()"
@@ -26,7 +26,10 @@ import {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
import type { PlayerMediaTitle } from '../player-controls/player-controls.model';
import type {
PlayerMediaTitle,
PlayerUpNextItem,
} from '../player-controls/player-controls.model';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
@@ -82,6 +85,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -12,6 +12,7 @@
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -29,6 +29,7 @@ import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -81,6 +82,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@Output() timeUpdate = new EventEmitter<{
@@ -1,4 +1,5 @@
import {
formatRemainingTime,
formatTime,
persistVolume,
readStoredVolume,
@@ -18,6 +19,20 @@ describe('controls format utilities', () => {
expect(formatTime(3661)).toBe('1:01:01');
});
it('formats the remaining time with a minus sign, rounding up', () => {
expect(formatRemainingTime(30, 600)).toBe('−9:30');
expect(formatRemainingTime(599.2, 600)).toBe('−0:01');
expect(formatRemainingTime(700, 600)).toBe('−0:00');
expect(formatRemainingTime(-5, 60)).toBe('−1:00');
});
it('has no remaining time without a finite positive duration', () => {
expect(formatRemainingTime(30, null)).toBeNull();
expect(formatRemainingTime(30, undefined)).toBeNull();
expect(formatRemainingTime(30, 0)).toBeNull();
expect(formatRemainingTime(30, Number.POSITIVE_INFINITY)).toBeNull();
});
it('clamps stored volume reads and persists raw volume values', () => {
localStorage.setItem('volume', '2');
expect(readStoredVolume()).toBe(1);
@@ -15,6 +15,29 @@ export function formatTime(value: number | null | undefined): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
/**
* Time left until the end, as `−7:03`. The dock shows it in place of the
* total duration: it is the number a viewer actually wants at a glance.
* Null when there is no finite duration to count down from.
*/
export function formatRemainingTime(
positionSeconds: number,
durationSeconds: number | null | undefined
): string | null {
if (
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return null;
}
const remaining = Math.max(
0,
durationSeconds - Math.max(0, positionSeconds)
);
return `−${formatTime(Math.ceil(remaining))}`;
}
export function volumeIcon(value: number): string {
if (value <= 0) {
return 'volume_off';
@@ -0,0 +1,127 @@
import {
COMPACT_LAYOUT_MAX_WIDTH,
ControlsLayout,
ROOMY_LAYOUT_MIN_WIDTH,
} from './controls-layout';
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
describe('ControlsLayout', () => {
const originalResizeObserver = globalThis.ResizeObserver;
let callbacks: ResizeCallback[];
let observed: Element[];
let disconnected: number;
function entry(width: number): ResizeObserverEntry {
return {
borderBoxSize: [{ inlineSize: width, blockSize: 100 }],
contentRect: { width } as DOMRectReadOnly,
} as unknown as ResizeObserverEntry;
}
beforeEach(() => {
callbacks = [];
observed = [];
disconnected = 0;
class FakeResizeObserver {
constructor(callback: ResizeCallback) {
callbacks.push(callback);
}
observe(target: Element): void {
observed.push(target);
}
disconnect(): void {
disconnected += 1;
}
unobserve(): void {
/* noop */
}
}
globalThis.ResizeObserver =
FakeResizeObserver as unknown as typeof ResizeObserver;
});
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver;
});
it('starts wide and observes the host', () => {
const layout = new ControlsLayout();
const host = document.createElement('div');
layout.attach(host);
expect(layout.mode()).toBe('wide');
expect(observed).toEqual([host]);
});
it('switches to compact at the breakpoint and back above it', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
callbacks[0]([entry(COMPACT_LAYOUT_MAX_WIDTH)]);
expect(layout.mode()).toBe('compact');
callbacks[0]([entry(COMPACT_LAYOUT_MAX_WIDTH + 1)]);
expect(layout.mode()).toBe('wide');
});
it('reports room for chips and the side panel only from the roomy width', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
expect(layout.roomy()).toBe(true);
callbacks[0]([entry(ROOMY_LAYOUT_MIN_WIDTH - 1)]);
expect(layout.mode()).toBe('wide');
expect(layout.roomy()).toBe(false);
callbacks[0]([entry(ROOMY_LAYOUT_MIN_WIDTH)]);
expect(layout.roomy()).toBe(true);
callbacks[0]([entry(400)]);
expect(layout.mode()).toBe('compact');
expect(layout.roomy()).toBe(false);
});
it('uses the last entry of a batch and ignores zero widths', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
callbacks[0]([entry(1200), entry(400)]);
expect(layout.mode()).toBe('compact');
callbacks[0]([entry(0)]);
expect(layout.mode()).toBe('compact');
});
it('falls back to the content rect when border box sizes are missing', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
callbacks[0]([
{
contentRect: { width: 300 } as DOMRectReadOnly,
} as unknown as ResizeObserverEntry,
]);
expect(layout.mode()).toBe('compact');
});
it('disconnects on dispose and on re-attach', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
layout.attach(document.createElement('div'));
expect(disconnected).toBe(1);
layout.dispose();
expect(disconnected).toBe(2);
});
it('stays wide without ResizeObserver support', () => {
globalThis.ResizeObserver =
undefined as unknown as typeof ResizeObserver;
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
expect(layout.mode()).toBe('wide');
expect(callbacks).toHaveLength(0);
});
});
@@ -0,0 +1,83 @@
import { signal } from '@angular/core';
/**
* Container width (px) below which the controls switch to the compact
* layout: smaller buttons, volume behind a popover, secondary actions
* folded away. Mirrors the `@container player-controls (max-width: 719px)`
* query in the stylesheet, which owns the purely visual sizing; the signal
* exists for the template branches CSS cannot express (which elements are
* rendered at all).
*/
export const COMPACT_LAYOUT_MAX_WIDTH = 719;
/**
* Container width (px) from which the wide dock has room for its extras:
* the subtitle/speed value chips beside the action buttons, and the
* settings panel beside the video (the panel takes 370px of the dock).
* Between the compact breakpoint and this width the dock stays wide but
* folds the chips into `tune` and opens settings as a bottom sheet.
* Sized from the widest action row: volume + series transport + two chips
* + tune/record/PiP/fullscreen, and dock + panel with only tune/fullscreen.
*/
export const ROOMY_LAYOUT_MIN_WIDTH = 960;
export type ControlsLayoutMode = 'compact' | 'wide';
/**
* Observes the controls host's own width and reports the layout mode. The
* host fills the player box (`inset: 0`), so its width is the player's width
* — a small inline player inside a wide desktop window is compact, a
* fullscreen phone is compact, and a windowed desktop player is wide.
*
* Without `ResizeObserver` (unit tests, very old runtimes) the mode stays
* `wide`, which renders every control and is the safe default.
*/
export class ControlsLayout {
readonly mode = signal<ControlsLayoutMode>('wide');
/** Room for chips and the side panel; see {@link ROOMY_LAYOUT_MIN_WIDTH}. */
readonly roomy = signal(true);
private observer: ResizeObserver | null = null;
attach(host: HTMLElement): void {
this.detach();
if (typeof ResizeObserver === 'undefined') {
return;
}
this.observer = new ResizeObserver((entries) => {
const entry = entries[entries.length - 1];
if (!entry) {
return;
}
const width =
entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
this.applyWidth(width);
});
this.observer.observe(host);
this.applyWidth(host.getBoundingClientRect().width);
}
/** Exposed for hosts and tests that already know the width. */
applyWidth(width: number): void {
if (!Number.isFinite(width) || width <= 0) {
return;
}
const next: ControlsLayoutMode =
width <= COMPACT_LAYOUT_MAX_WIDTH ? 'compact' : 'wide';
if (this.mode() !== next) {
this.mode.set(next);
}
const roomy = width >= ROOMY_LAYOUT_MIN_WIDTH;
if (this.roomy() !== roomy) {
this.roomy.set(roomy);
}
}
detach(): void {
this.observer?.disconnect();
this.observer = null;
}
dispose(): void {
this.detach();
}
}
@@ -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();
}
}
@@ -16,31 +16,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();
@@ -48,11 +60,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',
});
@@ -63,37 +71,21 @@ 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);
});
@@ -106,12 +98,20 @@ describe('ControlsMenuState', () => {
]
>([
['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 }, {}],
])(
'maps runtime controller state to %s menu availability',
(menu, capabilityOverrides, stateOverrides) => {
@@ -123,32 +123,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 = computed(() => 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);
}
}
@@ -0,0 +1,97 @@
import { signal } from '@angular/core';
import {
ControlsTimelineHover,
projectPointerToSeconds,
} from './controls-timeline-hover';
describe('projectPointerToSeconds', () => {
const rect = { left: 100, width: 200 };
it('maps the pointer across the bar to the duration', () => {
expect(projectPointerToSeconds(100, rect, 600)).toBe(0);
expect(projectPointerToSeconds(200, rect, 600)).toBe(300);
expect(projectPointerToSeconds(300, rect, 600)).toBe(600);
});
it('clamps positions outside the bar', () => {
expect(projectPointerToSeconds(20, rect, 600)).toBe(0);
expect(projectPointerToSeconds(999, rect, 600)).toBe(600);
});
it('returns null without a usable bar or duration', () => {
expect(projectPointerToSeconds(150, { left: 0, width: 0 }, 600)).toBe(
null
);
expect(projectPointerToSeconds(150, rect, 0)).toBeNull();
expect(projectPointerToSeconds(Number.NaN, rect, 600)).toBeNull();
});
});
describe('ControlsTimelineHover', () => {
function createHover(duration = 600, interactive = true) {
const durationSignal = signal(duration);
const interactiveSignal = signal(interactive);
const hover = new ControlsTimelineHover({
duration: durationSignal,
interactive: interactiveSignal,
});
const bar = document.createElement('div');
bar.getBoundingClientRect = () => ({ left: 0, width: 400 }) as DOMRect;
return { hover, bar, durationSignal, interactiveSignal };
}
function pointer(clientX: number, pointerType = 'mouse'): PointerEvent {
return { clientX, pointerType } as PointerEvent;
}
it('reports the hovered time, percent and label', () => {
const { hover, bar } = createHover();
hover.move(pointer(100), bar);
expect(hover.seconds()).toBe(150);
expect(hover.percent()).toBe(25);
expect(hover.label()).toBe('2:30');
});
it('clears on leave', () => {
const { hover, bar } = createHover();
hover.move(pointer(100), bar);
hover.clear();
expect(hover.seconds()).toBeNull();
expect(hover.percent()).toBeNull();
expect(hover.label()).toBeNull();
});
it('ignores touch pointers and non-interactive timelines', () => {
const { hover, bar, interactiveSignal } = createHover();
hover.move(pointer(100, 'touch'), bar);
expect(hover.seconds()).toBeNull();
interactiveSignal.set(false);
hover.move(pointer(100), bar);
expect(hover.seconds()).toBeNull();
});
it('reads the bar from the event target when none is passed', () => {
const { hover, bar } = createHover();
hover.move({ ...pointer(200), currentTarget: bar } as PointerEvent);
expect(hover.seconds()).toBe(300);
hover.move({ ...pointer(200), currentTarget: null } as PointerEvent);
expect(hover.seconds()).toBeNull();
});
it('drops a stale hover once the duration disappears', () => {
const { hover, bar, durationSignal } = createHover();
hover.move(pointer(100), bar);
durationSignal.set(0);
expect(hover.percent()).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { Signal, computed, signal } from '@angular/core';
import { formatTime } from './controls-format.utils';
import {
findTimelineSegment,
type TimelineSegmentView,
} from './controls-timeline-segments';
/**
* Projects a pointer's horizontal position over the timeline bar onto a
* playback time (seconds), clamped to the bar. Null when the bar has no
* width or the duration is unknown.
*/
export function projectPointerToSeconds(
clientX: number,
rect: Pick<DOMRect, 'left' | 'width'>,
durationSeconds: number
): number | null {
if (
!Number.isFinite(clientX) ||
!Number.isFinite(rect.width) ||
rect.width <= 0 ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return null;
}
const fraction = Math.min(
1,
Math.max(0, (clientX - rect.left) / rect.width)
);
return fraction * durationSeconds;
}
export interface ControlsTimelineHoverDeps {
duration: Signal<number>;
/** Whether the timeline accepts pointer interaction at all. */
interactive: Signal<boolean>;
/** Drawn segments, for the title in the label; none means time only. */
segments?: Signal<readonly TimelineSegmentView[]>;
}
/**
* Hover state of the timeline: the time under the pointer, its position as
* a percentage of the bar (for the marker and label), and the label text.
* Touch never hovers, so a coarse pointer leaves this empty.
*/
export class ControlsTimelineHover {
readonly seconds = signal<number | null>(null);
constructor(private readonly deps: ControlsTimelineHoverDeps) {}
readonly percent = computed(() => {
const seconds = this.seconds();
const duration = this.deps.duration();
if (seconds === null || duration <= 0) {
return null;
}
return Math.min(100, Math.max(0, (seconds / duration) * 100));
});
/** The titled segment under the pointer, if any. */
readonly segmentTitle = computed(() => {
const seconds = this.seconds();
const segments = this.deps.segments?.();
if (seconds === null || !segments) {
return null;
}
return findTimelineSegment(segments, seconds)?.title ?? null;
});
/** `Chapter 2 · 12:40` over a titled segment, else the time alone. */
readonly label = computed(() => {
const seconds = this.seconds();
if (seconds === null) {
return null;
}
const time = formatTime(seconds);
const title = this.segmentTitle();
return title ? `${title} \u00b7 ${time}` : time;
});
/** Bound to `pointermove` on the bar element itself (`currentTarget`). */
move(event: PointerEvent, bar?: HTMLElement): void {
const target = bar ?? event.currentTarget;
if (
!(target instanceof HTMLElement) ||
!this.deps.interactive() ||
event.pointerType === 'touch'
) {
this.clear();
return;
}
this.seconds.set(
projectPointerToSeconds(
event.clientX,
target.getBoundingClientRect(),
this.deps.duration()
)
);
}
clear(): void {
if (this.seconds() !== null) {
this.seconds.set(null);
}
}
}
@@ -0,0 +1,150 @@
import {
findTimelineSegment,
normalizeTimelineSegments,
segmentFillPercent,
} from './controls-timeline-segments';
describe('normalizeTimelineSegments', () => {
it('renders one untitled segment without input or duration', () => {
expect(normalizeTimelineSegments(null, 600)).toEqual([
expect.objectContaining({
startSeconds: 0,
endSeconds: 600,
title: null,
share: 1,
}),
]);
expect(normalizeTimelineSegments([], 600)).toHaveLength(1);
expect(
normalizeTimelineSegments(
[{ startSeconds: 0, endSeconds: 10, title: 'Intro' }],
0
)
).toEqual([
expect.objectContaining({ startSeconds: 0, title: null, share: 1 }),
]);
expect(
normalizeTimelineSegments([], Number.POSITIVE_INFINITY)
).toHaveLength(1);
});
it('sorts, fills the gaps and shares the duration', () => {
const segments = normalizeTimelineSegments(
[
{ startSeconds: 300, endSeconds: 450, title: 'Chapter 2' },
{ startSeconds: 60, endSeconds: 300, title: 'Chapter 1' },
],
600
);
expect(segments).toEqual([
expect.objectContaining({
startSeconds: 0,
endSeconds: 60,
title: null,
share: 0.1,
}),
expect.objectContaining({
startSeconds: 60,
endSeconds: 300,
title: 'Chapter 1',
share: 0.4,
}),
expect.objectContaining({
startSeconds: 300,
endSeconds: 450,
title: 'Chapter 2',
share: 0.25,
}),
expect.objectContaining({
startSeconds: 450,
endSeconds: 600,
title: null,
share: 0.25,
}),
]);
expect(
segments.reduce((total, segment) => total + segment.share, 0)
).toBeCloseTo(1);
});
it('places segments at their time positions with a gap after all but the last', () => {
const segments = normalizeTimelineSegments(
[
{ startSeconds: 0, endSeconds: 150, title: 'A' },
{ startSeconds: 150, endSeconds: 600, title: 'B' },
],
600
);
expect(segments.map((s) => s.startPercent)).toEqual([0, 25]);
expect(segments.map((s) => s.width)).toEqual([
'max(0px, calc(25% - 3px))',
'75%',
]);
});
it('clamps to the duration, cuts overlaps and drops empty segments', () => {
const segments = normalizeTimelineSegments(
[
{ startSeconds: -20, endSeconds: 100, title: ' A ' },
{ startSeconds: 50, endSeconds: 200, title: 'B' },
{ startSeconds: 200, endSeconds: 200, title: 'empty' },
{ startSeconds: 400, endSeconds: 300, title: 'reversed' },
{ startSeconds: 150, endSeconds: 180, title: 'swallowed' },
{ startSeconds: 500, endSeconds: 900, title: '' },
],
600
);
expect(
segments.map((s) => [s.startSeconds, s.endSeconds, s.title])
).toEqual([
[0, 100, 'A'],
[100, 200, 'B'],
[200, 500, null],
[500, 600, null],
]);
});
});
describe('segmentFillPercent', () => {
const segment = { startSeconds: 100, endSeconds: 300 };
it('measures the played share of a segment and clamps outside it', () => {
expect(segmentFillPercent(segment, 50)).toBe(0);
expect(segmentFillPercent(segment, 150)).toBe(25);
expect(segmentFillPercent(segment, 300)).toBe(100);
expect(segmentFillPercent(segment, 999)).toBe(100);
});
it('is empty for degenerate segments and positions', () => {
expect(segmentFillPercent({ startSeconds: 5, endSeconds: 5 }, 5)).toBe(
0
);
expect(segmentFillPercent(segment, Number.NaN)).toBe(0);
});
});
describe('findTimelineSegment', () => {
const segments = normalizeTimelineSegments(
[
{ startSeconds: 0, endSeconds: 100, title: 'First' },
{ startSeconds: 100, endSeconds: 200, title: 'Second' },
],
200
);
it('returns the segment containing the time, the last one at the end', () => {
expect(findTimelineSegment(segments, 0)?.title).toBe('First');
expect(findTimelineSegment(segments, 99.9)?.title).toBe('First');
expect(findTimelineSegment(segments, 100)?.title).toBe('Second');
expect(findTimelineSegment(segments, 200)?.title).toBe('Second');
});
it('returns null for nothing to match', () => {
expect(findTimelineSegment([], 10)).toBeNull();
expect(findTimelineSegment(segments, Number.NaN)).toBeNull();
expect(findTimelineSegment(segments, -1)).toBeNull();
});
});
@@ -0,0 +1,128 @@
import type { PlayerTimelineSegment } from './player-controls.model';
/** A normalized segment as the track renders it. */
export interface TimelineSegmentView {
startSeconds: number;
endSeconds: number;
title: string | null;
/** Share of the whole duration, 0..1. */
share: number;
/** Left edge on the track, 0..100 — the same mapping the seek input uses. */
startPercent: number;
/** CSS width: the share, minus the gap every segment but the last keeps. */
width: string;
}
/** Visual gap (px) after every segment except the last. */
export const TIMELINE_SEGMENT_GAP_PX = 3;
const WHOLE_TIMELINE: TimelineSegmentView = {
startSeconds: 0,
endSeconds: 0,
title: null,
share: 1,
startPercent: 0,
width: '100%',
};
/**
* Turns host-supplied segments (chapters, programmes) into a gapless,
* non-overlapping cover of `[0, duration]`, in playback order. Segments are
* clamped to the duration, empty and reversed ones dropped, overlaps cut at
* the previous segment's end, and every uncovered stretch — before the
* first, between two, after the last — becomes an untitled segment so the
* track always adds up to the full duration. Without a usable duration or
* without segments the whole timeline is one untitled segment, which is
* exactly the pre-segment rendering.
*/
export function normalizeTimelineSegments(
segments: readonly PlayerTimelineSegment[] | null | undefined,
durationSeconds: number
): TimelineSegmentView[] {
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
return [WHOLE_TIMELINE];
}
const clamp = (value: number) =>
Number.isFinite(value)
? Math.min(durationSeconds, Math.max(0, value))
: 0;
const ordered = (segments ?? [])
.map((segment) => ({
startSeconds: clamp(segment.startSeconds),
endSeconds: clamp(segment.endSeconds),
title: segment.title?.trim() || null,
}))
.filter((segment) => segment.endSeconds > segment.startSeconds)
.sort((a, b) => a.startSeconds - b.startSeconds);
const cover: Pick<
TimelineSegmentView,
'startSeconds' | 'endSeconds' | 'title'
>[] = [];
let cursor = 0;
for (const segment of ordered) {
const startSeconds = Math.max(segment.startSeconds, cursor);
if (startSeconds >= segment.endSeconds) {
continue;
}
if (startSeconds > cursor) {
cover.push({
startSeconds: cursor,
endSeconds: startSeconds,
title: null,
});
}
cover.push({ ...segment, startSeconds });
cursor = segment.endSeconds;
}
if (cursor < durationSeconds) {
cover.push({
startSeconds: cursor,
endSeconds: durationSeconds,
title: null,
});
}
return cover.map((segment, index) => {
const share =
(segment.endSeconds - segment.startSeconds) / durationSeconds;
const last = index === cover.length - 1;
return {
...segment,
share,
startPercent: (segment.startSeconds / durationSeconds) * 100,
width: last
? `${share * 100}%`
: `max(0px, calc(${share * 100}% - ${TIMELINE_SEGMENT_GAP_PX}px))`,
};
});
}
/** How much of one segment the position has played through, 0..100. */
export function segmentFillPercent(
segment: Pick<TimelineSegmentView, 'startSeconds' | 'endSeconds'>,
positionSeconds: number
): number {
const length = segment.endSeconds - segment.startSeconds;
if (length <= 0 || !Number.isFinite(positionSeconds)) {
return 0;
}
const fraction = (positionSeconds - segment.startSeconds) / length;
return Math.min(100, Math.max(0, fraction * 100));
}
/** The segment containing `seconds`; the last one at the very end. */
export function findTimelineSegment(
segments: readonly TimelineSegmentView[],
seconds: number
): TimelineSegmentView | null {
if (!Number.isFinite(seconds)) {
return null;
}
for (const segment of segments) {
if (seconds >= segment.startSeconds && seconds < segment.endSeconds) {
return segment;
}
}
const last = segments[segments.length - 1];
return last && seconds >= last.endSeconds ? last : null;
}
@@ -1,5 +1,13 @@
import { Signal, computed, signal } from '@angular/core';
import type { PlayerControlsState } from './player-controls.model';
import {
normalizeTimelineSegments,
segmentFillPercent,
type TimelineSegmentView,
} from './controls-timeline-segments';
import type {
PlayerControlsState,
PlayerTimelineSegment,
} from './player-controls.model';
/**
* Owns the scrub state and timeline projections for the controls bar: the
@@ -9,7 +17,12 @@ import type { PlayerControlsState } from './player-controls.model';
export class ControlsTimeline {
readonly scrubPosition = signal<number | null>(null);
constructor(private readonly state: Signal<PlayerControlsState>) {}
constructor(
private readonly state: Signal<PlayerControlsState>,
private readonly hostSegments: Signal<
readonly PlayerTimelineSegment[] | null
> = signal(null)
) {}
readonly duration = computed(() => {
const duration = this.state().durationSeconds;
@@ -32,6 +45,19 @@ export class ControlsTimeline {
: 0;
});
/** The drawn track: host segments over the duration, else one segment. */
readonly segments = computed<TimelineSegmentView[]>(() =>
normalizeTimelineSegments(this.hostSegments(), this.duration())
);
/** Played share of one segment for the current (scrub or playback) value. */
fillPercent(segment: TimelineSegmentView): number {
if (!this.state().canSeek) {
return 0;
}
return segmentFillPercent(segment, this.value());
}
readEventValue(event: Event): number | null {
return this.normalize(Number((event.target as HTMLInputElement).value));
}
@@ -0,0 +1,142 @@
import { WritableSignal, signal } from '@angular/core';
import { ControlsUpNext, UP_NEXT_THRESHOLD_SECONDS } from './controls-up-next';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
PlayerUpNextItem,
} from './player-controls.model';
describe('ControlsUpNext', () => {
const next: PlayerUpNextItem = {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: null,
progressPercent: null,
};
let item: WritableSignal<PlayerUpNextItem | null>;
let state: WritableSignal<PlayerControlsState>;
let capabilities: WritableSignal<PlayerControlsCapabilities>;
let showControls: WritableSignal<boolean>;
let settingsOpen: WritableSignal<boolean>;
let upNext: ControlsUpNext;
const setState = (overrides: Partial<PlayerControlsState>) =>
state.set({ ...createEmptyControlsState(), ...overrides });
beforeEach(() => {
item = signal(next);
state = signal(createEmptyControlsState());
capabilities = signal({
...DEFAULT_PLAYER_CAPABILITIES,
seriesNavigation: true,
});
showControls = signal(true);
settingsOpen = signal(false);
upNext = new ControlsUpNext({
item,
state,
capabilities,
showControls,
settingsOpen,
});
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - 7 * 60 - 3,
});
});
it('shows the next episode inside the threshold with minutes left', () => {
expect(upNext.visible()).toBe(true);
expect(upNext.item()).toBe(next);
expect(upNext.remainingSeconds()).toBe(423);
expect(upNext.minutesLeft()).toBe(8);
});
it('stays hidden until the threshold and reports at least one minute', () => {
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS - 1,
});
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS,
});
expect(upNext.visible()).toBe(true);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1199.5,
});
expect(upNext.minutesLeft()).toBe(1);
});
it('needs a supplied episode, a finite duration and a series-capable engine', () => {
item.set(null);
expect(upNext.visible()).toBe(false);
item.set(next);
// The host's item is authoritative: at a season's last episode the
// transport cannot step forward, but the next season's first can.
setState({
canNextEpisode: false,
durationSeconds: 1200,
positionSeconds: 1100,
});
expect(upNext.visible()).toBe(true);
setState({
canNextEpisode: true,
durationSeconds: null,
positionSeconds: 1100,
});
expect(upNext.visible()).toBe(false);
expect(upNext.remainingSeconds()).toBeNull();
setState({
canNextEpisode: true,
isLive: true,
durationSeconds: 1200,
positionSeconds: 1100,
});
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1100,
});
capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
expect(upNext.visible()).toBe(false);
});
it('does not count down once the episode has ended', () => {
setState({
status: 'ended',
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200,
});
expect(upNext.visible()).toBe(false);
});
it('yields to hidden controls and to the open settings panel', () => {
showControls.set(false);
expect(upNext.visible()).toBe(false);
showControls.set(true);
settingsOpen.set(true);
expect(upNext.visible()).toBe(false);
settingsOpen.set(false);
expect(upNext.visible()).toBe(true);
});
});
@@ -0,0 +1,69 @@
import { Signal, computed } from '@angular/core';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
PlayerUpNextItem,
} from './player-controls.model';
/** The card appears once this little of the episode is left. */
export const UP_NEXT_THRESHOLD_SECONDS = 8 * 60;
export interface ControlsUpNextDeps {
item: Signal<PlayerUpNextItem | null>;
state: Signal<PlayerControlsState>;
capabilities: Signal<PlayerControlsCapabilities>;
showControls: Signal<boolean>;
/** The settings panel covers the card's corner; it yields while open. */
settingsOpen: Signal<boolean>;
}
/**
* When the "Up next" card shows: a series host supplied the next episode
* (possibly the first of the following season, which the transport's
* season-local `canNextEpisode` does not cover), the engine plays series,
* the episode has a known length, and no more than the threshold is left. Live streams and open-ended
* VOD never qualify because they have no remaining time to count down.
*/
export class ControlsUpNext {
constructor(private readonly deps: ControlsUpNextDeps) {}
readonly remainingSeconds = computed(() => {
const { durationSeconds, positionSeconds } = this.deps.state();
if (
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return null;
}
return Math.max(0, durationSeconds - Math.max(0, positionSeconds));
});
readonly item = computed<PlayerUpNextItem | null>(() => {
const item = this.deps.item();
const remaining = this.remainingSeconds();
const state = this.deps.state();
if (
!item ||
remaining === null ||
remaining > UP_NEXT_THRESHOLD_SECONDS ||
state.isLive ||
// An ended episode with autoplay off schedules no switch: a
// countdown would promise one. Autoplay replaces the playback.
state.status === 'ended' ||
!this.deps.capabilities().seriesNavigation ||
!this.deps.showControls() ||
this.deps.settingsOpen()
) {
return null;
}
return item;
});
readonly visible = computed(() => this.item() !== null);
/** Whole minutes left, never below one while the card is showing. */
readonly minutesLeft = computed(() =>
Math.max(1, Math.ceil((this.remainingSeconds() ?? 0) / 60))
);
}
@@ -45,8 +45,14 @@ export class ControlsVolumeInteractions {
this.deps.volume.hoverLeave();
}
buttonClick(event?: Event): void {
/**
* With the slider rendered inline next to the button (wide dock), there
* is no popover to open first: the button is plainly a mute toggle for
* every pointer type.
*/
buttonClick(event?: Event, options: { inlineSlider?: boolean } = {}): void {
if (
!options.inlineSlider &&
this.deps.wasTouchInteraction(event) &&
!this.deps.menus.volumeOpen()
) {
@@ -108,21 +108,18 @@ describe('PlayerControlsComponent capability contract', () => {
return event.defaultPrevented;
}
it('keeps narrow-player popovers outside clipping scroll containers', () => {
it('lets the compact action cluster wrap instead of clipping or scrolling', () => {
const narrowActionsRule =
PLAYER_CONTROLS_STYLE_SOURCE.match(
/\.player-controls__actions\s*\{([\s\S]*?)\}/g
)?.at(-1) ?? '';
expect(PLAYER_CONTROLS_STYLE_SOURCE).toContain(
'@container player-controls (max-width: 640px)'
'@container player-controls (max-width: 719px)'
);
expect(narrowActionsRule).toContain('flex-wrap: wrap');
expect(narrowActionsRule).toContain('overflow: visible');
expect(narrowActionsRule).not.toContain('overflow-x: auto');
expect(PLAYER_CONTROLS_STYLE_SOURCE).toMatch(
/\.player-controls__actions \.player-controls__popover-anchor\s*\{\s*position:\s*static;/
);
});
it('routes shortcuts to the player the user interacted with most recently', () => {
@@ -236,8 +233,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 +248,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 +289,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 +311,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 +321,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();
});
@@ -348,7 +347,7 @@ describe('PlayerControlsComponent capability contract', () => {
const root = fixture.nativeElement as HTMLElement;
expect(
root.querySelector('.player-controls__timeline > input')
root.querySelector('.player-controls__slider--timeline')
).toBeNull();
expect(
root.querySelector('.player-controls__live-badge')
@@ -0,0 +1,567 @@
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 "Hybrid" dock: scrim backdrop, remaining time, timeline hover label,
* the accent play button, and the compact/wide layout branches.
*/
describe('PlayerControlsComponent dock', () => {
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 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();
};
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: {
PLAY: 'Play',
PAUSE: 'Pause',
PREVIOUS_EPISODE: 'Previous episode',
NEXT_EPISODE: 'Next episode',
BACK_10_SECONDS: 'Back 10 seconds',
FORWARD_10_SECONDS: 'Forward 10 seconds',
MUTE: 'Mute',
UNMUTE: 'Unmute',
},
},
});
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;
});
it('backs the dock with a bottom scrim that fades with the controls', () => {
const scrim = query('[data-test-id="player-controls-bottom-scrim"]');
expect(scrim).not.toBeNull();
expect(scrim?.classList).toContain(
'player-controls__bottom-scrim--visible'
);
expect(scrim?.getAttribute('aria-hidden')).toBe('true');
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-bottom-scrim"]')
).toBeNull();
});
it('renders the accent play button with Play/Pause names', () => {
const play = query('[data-test-id="player-controls-play"]');
expect(play?.tagName).toBe('BUTTON');
expect(play?.getAttribute('aria-label')).toBe('Play');
expect(play?.querySelector('mat-icon')?.textContent).toBe('play_arrow');
setState({ status: 'playing' });
fixture.detectChanges();
expect(play?.getAttribute('aria-label')).toBe('Pause');
expect(play?.querySelector('mat-icon')?.textContent).toBe('pause');
play?.click();
expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1);
});
it('orders the wide transport as previous, back, play, forward, next', () => {
setCapabilities({ seek: true, seriesNavigation: true });
setState({ canSeek: true, durationSeconds: 100 });
fixture.detectChanges();
const labels = Array.from(
fixture.nativeElement.querySelectorAll(
'.player-controls__transport button'
) as NodeListOf<HTMLElement>
).map((button) => button.getAttribute('aria-label'));
expect(labels).toEqual([
'Previous episode',
'Back 10 seconds',
'Play',
'Forward 10 seconds',
'Next episode',
]);
});
describe('timeline row', () => {
it('shows the remaining time instead of the total duration', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 600,
positionSeconds: 30,
});
fixture.detectChanges();
expect(query('.player-controls__time--current')?.textContent).toBe(
'0:30'
);
expect(
query('[data-test-id="player-controls-remaining-time"]')
?.textContent
).toBe('−9:30');
expect(query('.player-controls__live-badge')).toBeNull();
});
it('follows the scrub preview in the remaining time', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 600,
positionSeconds: 30,
});
fixture.detectChanges();
const slider = query(
'.player-controls__slider--timeline'
) as HTMLInputElement;
slider.value = '540';
slider.dispatchEvent(new Event('input', { bubbles: true }));
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-remaining-time"]')
?.textContent
).toBe('−1:00');
});
it('shows a placeholder for unknown durations and LIVE for live streams', () => {
setState({ canSeek: false, isLive: false });
fixture.detectChanges();
expect(query('.player-controls__time--end')?.textContent).toBe(
'--:--'
);
setState({ canSeek: false, isLive: true });
fixture.detectChanges();
expect(query('.player-controls__time--end')).toBeNull();
expect(query('.player-controls__live-badge')).not.toBeNull();
});
it('draws the progress fill and knob from the timeline progress', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 200,
positionSeconds: 50,
});
fixture.detectChanges();
expect(query('.player-controls__timeline-fill')?.style.width).toBe(
'25%'
);
expect(query('.player-controls__timeline-knob')?.style.left).toBe(
'25%'
);
setState({
canSeek: false,
durationSeconds: 200,
positionSeconds: 50,
});
fixture.detectChanges();
expect(query('.player-controls__timeline-knob')).toBeNull();
expect(
query('.player-controls__timeline-bar')?.classList
).toContain('player-controls__timeline-bar--disabled');
});
it('shows the hovered time above the bar and clears it on leave', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 400,
positionSeconds: 0,
});
fixture.detectChanges();
const bar = query('.player-controls__timeline-bar') as HTMLElement;
bar.getBoundingClientRect = () =>
({ left: 100, width: 200 }) as DOMRect;
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 150, bubbles: true })
);
fixture.detectChanges();
const label = query(
'[data-test-id="player-controls-timeline-label"]'
);
expect(label?.textContent?.trim()).toBe('1:40');
expect(label?.style.getPropertyValue('--hover-x')).toBe('25%');
expect(query('.player-controls__timeline-marker')?.style.left).toBe(
'25%'
);
expect(fake.commands.seekTo).not.toHaveBeenCalled();
bar.dispatchEvent(
new MouseEvent('pointerleave', { bubbles: true })
);
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-timeline-label"]')
).toBeNull();
});
it('draws host segments proportionally with per-segment fills', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 400,
positionSeconds: 150,
});
fixture.componentRef.setInput('timelineSegments', [
{ startSeconds: 100, endSeconds: 300, title: 'Chapter 2' },
{ startSeconds: 0, endSeconds: 100, title: 'Chapter 1' },
]);
fixture.detectChanges();
const segments = Array.from(
fixture.nativeElement.querySelectorAll(
'.player-controls__timeline-segment'
) as NodeListOf<HTMLElement>
);
expect(
segments.map((s) => s.dataset['segmentTitle'] ?? null)
).toEqual(['Chapter 1', 'Chapter 2', null]);
// Positioned by time, so boundaries match the linear seek input.
expect(segments.map((s) => s.style.left)).toEqual([
'0%',
'25%',
'75%',
]);
expect(segments.at(-1)?.style.width).toBe('25%');
expect(
segments.map(
(s) =>
(
s.querySelector(
'.player-controls__timeline-fill'
) as HTMLElement
).style.width
)
).toEqual(['100%', '25%', '0%']);
// The knob still reads the overall progress.
expect(query('.player-controls__timeline-knob')?.style.left).toBe(
'37.5%'
);
});
it('names the hovered segment in the timeline label', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 400,
positionSeconds: 0,
});
fixture.componentRef.setInput('timelineSegments', [
{ startSeconds: 0, endSeconds: 200, title: 'Intro' },
]);
fixture.detectChanges();
const bar = query('.player-controls__timeline-bar') as HTMLElement;
bar.getBoundingClientRect = () =>
({ left: 0, width: 400 }) as DOMRect;
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 100, bubbles: true })
);
fixture.detectChanges();
expect(
query(
'[data-test-id="player-controls-timeline-label"]'
)?.textContent?.trim()
).toBe('Intro \u00b7 1:40');
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 300, bubbles: true })
);
fixture.detectChanges();
expect(
query(
'[data-test-id="player-controls-timeline-label"]'
)?.textContent?.trim()
).toBe('5:00');
});
it('does not hover-label a non-seekable timeline', () => {
setCapabilities({ seek: true });
setState({ canSeek: false, durationSeconds: 400 });
fixture.detectChanges();
const bar = query('.player-controls__timeline-bar') as HTMLElement;
bar.getBoundingClientRect = () =>
({ left: 0, width: 200 }) as DOMRect;
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 50, bubbles: true })
);
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-timeline-label"]')
).toBeNull();
});
});
describe('up next card', () => {
const nearTheEnd = () =>
setState({
canSeek: true,
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - 5 * 60,
});
beforeEach(() => {
setCapabilities({
seek: true,
seriesNavigation: true,
playbackSpeed: true,
});
fixture.componentRef.setInput('upNext', {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: null,
progressPercent: null,
});
fixture.detectChanges();
});
it('appears within the last minutes and plays the next episode on click', () => {
setState({
canSeek: true,
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 60,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
nearTheEnd();
fixture.detectChanges();
const card = query('[data-test-id="player-controls-up-next"]');
expect(card).not.toBeNull();
expect(card?.textContent).toContain('The Third One');
const next = jest.fn();
component.nextEpisodeRequested.subscribe(next);
card?.click();
expect(next).toHaveBeenCalledTimes(1);
});
it('offers the next season across the season boundary', () => {
setState({
canSeek: true,
canNextEpisode: false,
durationSeconds: 1200,
positionSeconds: 1200 - 5 * 60,
});
fixture.detectChanges();
const card = query('[data-test-id="player-controls-up-next"]');
expect(card).not.toBeNull();
// The transport's next button is disabled, but the card still
// hands the request to the host, which picks the next season.
expect(
(
query(
'[data-test-id="player-controls-next-episode"]'
) as HTMLButtonElement
).disabled
).toBe(true);
const next = jest.fn();
component.nextEpisodeRequested.subscribe(next);
card?.click();
expect(next).toHaveBeenCalledTimes(1);
});
it('yields to the settings panel and needs a next episode', () => {
nearTheEnd();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).not.toBeNull();
component.settings.open('speed');
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
component.settings.close();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).not.toBeNull();
fixture.componentRef.setInput('upNext', null);
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
});
it('uses the compact card on a compact dock', () => {
nearTheEnd();
resizeTo(400);
expect(
query('[data-test-id="player-controls-up-next"]')?.closest(
'.player-controls__up-next--compact'
)
).not.toBeNull();
});
});
describe('layout modes', () => {
beforeEach(() => {
setCapabilities({ volume: true });
fixture.detectChanges();
});
it('starts wide with the volume slider inline', () => {
expect(component.isCompact()).toBe(false);
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--compact'
);
expect(query('.player-controls__slider--inline')).not.toBeNull();
expect(query('.player-controls__volume-popover')).toBeNull();
});
it('mutes on a wide volume button click without opening a popover', () => {
component.volumeInteractions.hoverEnter();
fixture.detectChanges();
expect(query('.player-controls__volume-popover')).toBeNull();
(query('[aria-label="Mute"]') as HTMLButtonElement).dispatchEvent(
new MouseEvent('click', { bubbles: true })
);
fixture.detectChanges();
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(query('[aria-label="Unmute"]')).not.toBeNull();
});
it('moves the volume slider behind a popover once compact', () => {
resizeTo(COMPACT_LAYOUT_MAX_WIDTH);
expect(component.isCompact()).toBe(true);
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--compact'
);
expect(query('.player-controls__slider--inline')).toBeNull();
expect(query('.player-controls__volume-popover')).toBeNull();
component.volumeInteractions.hoverEnter();
fixture.detectChanges();
expect(query('.player-controls__volume-popover')).not.toBeNull();
resizeTo(COMPACT_LAYOUT_MAX_WIDTH + 200);
expect(component.isCompact()).toBe(false);
expect(query('.player-controls__slider--inline')).not.toBeNull();
});
it('keeps episode navigation reachable in the compact transport', () => {
setCapabilities({ volume: true, seriesNavigation: true });
resizeTo(400);
expect(
query('[data-test-id="player-controls-next-episode"]')
).not.toBeNull();
expect(
query('[data-test-id="player-controls-previous-episode"]')
).not.toBeNull();
});
});
});
File diff suppressed because it is too large. Load diff
@@ -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,21 +421,39 @@ 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);
});
it('mutes on a tap when the slider is already inline (wide dock)', () => {
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch'),
{ inlineSlider: true }
);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(component.menus.volumeOpen()).toBe(false);
});
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);
@@ -440,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);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,485 @@
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,
ROOMY_LAYOUT_MIN_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('wide dock without room for extras', () => {
beforeEach(() => {
withEverything();
resizeTo(ROOMY_LAYOUT_MIN_WIDTH - 1);
});
it('stays wide but folds the chips into tune and opens a sheet', () => {
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--compact'
);
expect(query('.player-controls__slider--inline')).not.toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
setState({
audioTracks: fake.state().audioTracks,
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-dots"]')
).not.toBeNull();
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
?.classList
).toContain('player-controls__settings--sheet');
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--sheet-open'
);
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--panel-open'
);
});
});
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('makes the hidden dock inert and moves keyboard focus into the sheet and back', async () => {
const tune = query(
'[data-test-id="player-controls-settings-button"]'
) as HTMLButtonElement;
document.body.appendChild(fixture.nativeElement);
tune.focus();
// jsdom has no keyboard modality: report the focused tune button
// as :focus-visible, as a keyboard focus would be.
const matches = tune.matches.bind(tune);
Object.defineProperty(tune, 'matches', {
configurable: true,
value: (selector: string) =>
selector === ':focus-visible' || matches(selector),
});
tune.click();
fixture.detectChanges();
await fixture.whenStable();
const sheet = query(
'[data-test-id="player-controls-settings-panel"]'
) as HTMLElement;
expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe(
true
);
expect(document.activeElement).toBe(sheet);
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe(
false
);
expect(document.activeElement).toBe(
query('[data-test-id="player-controls-settings-button"]')
);
fixture.nativeElement.remove();
});
it('leaves focus alone when a pointer opens the sheet', async () => {
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
await fixture.whenStable();
expect(document.activeElement).not.toBe(
query('[data-test-id="player-controls-settings-panel"]')
);
});
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);
@@ -61,7 +61,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();
};
@@ -98,7 +98,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 });
@@ -107,17 +107,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', () => {
@@ -154,8 +154,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');
(
@@ -199,7 +198,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) =>
@@ -222,7 +221,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 })
@@ -350,7 +350,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');
@@ -56,7 +56,7 @@ describe('PlayerControlsComponent timeline scrubbing', () => {
const currentTimeText = () =>
(
fixture.nativeElement.querySelector(
'.player-controls__time > span:first-child'
'.player-controls__time--current'
) as HTMLElement
).textContent;
@@ -17,34 +17,45 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ControlsChromeInteractions } from './controls-chrome-interactions';
import { ControlsFeedback } from './controls-feedback';
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';
import { ControlsTimeline } from './controls-timeline';
import { ControlsTimelineHover } from './controls-timeline-hover';
import { ControlsUpNext } from './controls-up-next';
import { ControlsVisibility } from './controls-visibility';
import { createControlsViewModel } from './controls-view-model';
import { ControlsVolume } from './controls-volume';
import { ControlsVolumeInteractions } from './controls-volume-interactions';
import { ControlsSubtitleSettings } from './controls-subtitle-settings';
import { formatTime, speedLabel } from './controls-format.utils';
import { formatRemainingTime } from './controls-format.utils';
import type {
PlayerController,
PlayerMediaTitle,
PlayerTimelineSegment,
PlayerUpNextItem,
} 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';
import { PlayerTimelineComponent } from './player-timeline.component';
import { PlayerUpNextCardComponent } from './player-up-next-card.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,
PlayerTimelineComponent,
PlayerUpNextCardComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-controls-host',
@@ -68,12 +79,21 @@ export class PlayerControlsComponent implements OnDestroy {
readonly showControls = input(true);
readonly shortcutsEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** Chapters / programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
/** The next episode, for the "Up next" card near the end of this one. */
readonly upNext = input<PlayerUpNextItem | null>(null);
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly menus = new ControlsMenuState();
readonly feedback = new ControlsFeedback();
readonly anyMenuOpen = this.menus.anyOpen;
private readonly shortcuts = new ControlsShortcuts();
readonly layout = new ControlsLayout();
/** Compact dock: narrow inline players and phone-sized viewports. */
readonly isCompact = computed(() => this.layout.mode() === 'compact');
private readonly visibility = new ControlsVisibility(() => this.canHide());
private readonly fullscreen = new ControlsFullscreen(
() => this.fullscreenTarget() ?? this.playerSurface(),
@@ -105,8 +125,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({
@@ -128,12 +146,35 @@ 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),
});
readonly upNextCard = new ControlsUpNext({
item: this.upNext,
state: this.state,
capabilities: this.capabilities,
showControls: this.showControls,
settingsOpen: this.settings.isOpen,
});
private readonly controllerVolume = computed(() => this.state().volume);
private readonly timeline = new ControlsTimeline(this.state);
readonly timeline = new ControlsTimeline(this.state, this.timelineSegments);
readonly scrubPosition = this.timeline.scrubPosition;
readonly timelineDuration = this.timeline.duration;
readonly timelineValue = this.timeline.value;
readonly timelineProgress = this.timeline.progress;
readonly timelineHover = new ControlsTimelineHover({
duration: this.timeline.duration,
interactive: computed(
() => this.capabilities().seek && this.state().canSeek
),
segments: this.timeline.segments,
});
/** `−7:03` while a finite duration is known; the dock prefers it to the total. */
readonly remainingTimeText = computed(() =>
formatRemainingTime(this.timeline.value(), this.timeline.duration())
);
readonly displayVolume = this.volume.value;
readonly isFullscreen = this.fullscreen.isFullscreen;
@@ -175,9 +216,6 @@ export class PlayerControlsComponent implements OnDestroy {
readonly isPaused = this.vm.isPaused;
readonly isPlaying = this.vm.isPlaying;
readonly canTogglePlay = this.vm.canTogglePlay;
readonly hasAudioTracks = this.vm.hasAudioTracks;
readonly hasSubtitleTracks = this.vm.hasSubtitleTracks;
readonly hasQualityLevels = this.vm.hasQualityLevels;
readonly canRecord = this.vm.canRecord;
readonly isRecording = this.vm.isRecording;
readonly recordingStatusText = this.vm.recordingStatusText;
@@ -189,6 +227,7 @@ export class PlayerControlsComponent implements OnDestroy {
readonly controlsAreVisible = this.vm.controlsAreVisible;
readonly hideCursor = this.vm.hideCursor;
constructor() {
this.layout.attach(this.host);
this.shortcuts.attach({
isAvailable: () => this.shortcutsEnabled() && this.showControls(),
hostElement: () => this.host,
@@ -282,6 +321,7 @@ export class PlayerControlsComponent implements OnDestroy {
});
}
ngOnDestroy(): void {
this.layout.dispose();
this.shortcuts.detach();
this.feedback.dispose();
this.visibility.dispose();
@@ -290,12 +330,6 @@ export class PlayerControlsComponent implements OnDestroy {
this.surface.dispose();
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()) {
@@ -350,12 +384,6 @@ export class PlayerControlsComponent implements OnDestroy {
this.reveal();
}
loadExternalSubtitle(): void {
if (!this.capabilities().externalSubtitles) {
return;
}
this.menuSelection.externalSubtitle();
}
toggleRecording(): void {
if (!this.canRecord()) {
return;
@@ -3,12 +3,7 @@ import type { PlayerSubtitleStyle } from '@iptvnator/shared/interfaces';
import type { PlayerStreamStatsSource } from './player-stream-stats.model';
export type PlayerStatus =
| 'idle'
| 'loading'
| 'playing'
| 'paused'
| 'ended'
| 'error';
'idle' | 'loading' | 'playing' | 'paused' | 'ended' | 'error';
/** Which controls an engine supports. A control is only rendered when its flag is true. */
export interface PlayerControlsCapabilities {
@@ -62,6 +57,31 @@ export interface PlayerMediaTitle {
secondary?: string | null;
}
/**
* One stretch of the timeline the track draws as its own segment — a chapter
* of a film, a programme of a catch-up recording. Hosts pass display-ready
* titles; the controls clamp, order and gap-fill the list themselves.
*/
export interface PlayerTimelineSegment {
startSeconds: number;
endSeconds: number;
/** Shown in the hover label as `Title · 12:40`; null for a plain gap. */
title: string | null;
}
/**
* The episode after the current one, for the "Up next" card. Series hosts
* build it from their episode list; the controls decide when to show it.
*/
export interface PlayerUpNextItem {
/** `S01E03`. */
label: string;
title: string;
thumbnailUrl: string | null;
/** Watch progress in percent (0–100); null when never started. */
progressPercent: number | null;
}
export interface PlayerPreset<T> {
value: T;
label: string;
@@ -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,448 @@
// The settings panel / bottom sheet. Reads the `--pc-*` palette that the
// controls host declares and places itself inside that host (the
// `player-controls` container): beside the video as a panel, or rising from
// the bottom edge as a sheet.
:host {
position: absolute;
top: 16px;
right: 16px;
bottom: 16px;
z-index: 4;
width: 330px;
max-width: calc(100cqw - 32px);
animation: player-settings-panel-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
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(:focus) {
outline: none;
}
:host(:focus-visible) {
box-shadow:
0 0 0 2px var(--pc-text, #e7ecf3),
0 12px 32px rgba(0, 0, 0, 0.45);
}
:host(.player-settings--sheet) {
top: auto;
right: 8px;
bottom: 0;
left: 8px;
width: auto;
max-width: none;
max-height: min(70%, calc(100cqh - 24px));
animation-name: player-settings-sheet-in;
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);
--mat-icon-button-icon-color: var(--pc-text, #e7ecf3);
--mat-icon-button-state-layer-size: 32px;
--mat-icon-button-container-shape: 10px;
--mat-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);
--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;
}
// 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;
}
@keyframes player-settings-panel-in {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes player-settings-sheet-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
:host {
animation: none;
}
}
@@ -0,0 +1,162 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
afterNextRender,
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',
tabindex: '-1',
'[class.player-settings--sheet]': 'mode() === "sheet"',
'[attr.aria-label]': 'title()',
'(focusin)': 'focusInside = true',
'(focusout)': 'onFocusOut($event)',
},
})
export class PlayerSettingsPanelComponent implements OnDestroy {
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;
/** The control that opened the panel, and whether the keyboard did. */
private readonly opener = document.activeElement;
private readonly openedByKeyboard =
this.opener instanceof HTMLElement &&
this.opener !== document.body &&
!!this.opener.closest('.player-controls-host') &&
matchesFocusVisible(this.opener);
focusInside = false;
/** Recorded after render: on destroy the panel is already detached. */
private controlsHost: Element | null = null;
constructor() {
// Keyboard users land inside the dialog; a pointer open leaves focus
// alone (a focused control would capture Space from the shortcuts).
afterNextRender(() => {
this.controlsHost = this.host.closest('.player-controls-host');
if (this.openedByKeyboard) {
this.host.focus({ preventScroll: true });
this.focusInside = this.host.contains(document.activeElement);
}
});
// 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));
});
}
onFocusOut(event: FocusEvent): void {
const next = event.relatedTarget;
this.focusInside = next instanceof Node && this.host.contains(next);
}
/**
* Closing with focus inside (Escape, the close button by keyboard)
* returns it to the `tune` button rather than dropping it on the page.
* The chip that may have opened the panel is re-rendered on close, so
* `tune` is the stable target. Tracked by flag, not `activeElement`:
* the view's DOM is already detached when this hook runs.
*/
ngOnDestroy(): void {
if (!this.focusInside) {
return;
}
const controls = this.controlsHost;
queueMicrotask(() =>
controls
?.querySelector<HTMLElement>('.player-controls__tune')
?.focus({ preventScroll: true })
);
}
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' });
}
}
}
function matchesFocusVisible(element: Element | null): boolean {
try {
return (
element instanceof HTMLElement && element.matches(':focus-visible')
);
} catch {
return false;
}
}
@@ -0,0 +1,98 @@
<span class="player-controls__time player-controls__time--current">{{
formatTime(timeline().value())
}}</span>
@if (seekable()) {
<div
class="player-controls__timeline-bar"
[class.player-controls__timeline-bar--disabled]="!canSeek()"
(pointermove)="hover().move($event)"
(pointerleave)="hover().clear()"
>
<div class="player-controls__timeline-track" aria-hidden="true">
@for (segment of timeline().segments(); track $index) {
<div
class="player-controls__timeline-segment"
[class.player-controls__timeline-segment--titled]="
segment.title !== null
"
[style.left.%]="segment.startPercent"
[style.width]="segment.width"
[attr.data-segment-title]="segment.title"
>
<div
class="player-controls__timeline-fill"
[style.width.%]="timeline().fillPercent(segment)"
></div>
</div>
}
</div>
@if (canSeek()) {
<div
class="player-controls__timeline-knob"
[style.left.%]="timeline().progress()"
aria-hidden="true"
></div>
}
@if (hover().percent() !== null) {
<div
class="player-controls__timeline-marker"
[style.left.%]="hover().percent()"
aria-hidden="true"
></div>
<div
class="player-controls__timeline-label"
[style.--hover-x.%]="hover().percent()"
data-test-id="player-controls-timeline-label"
aria-hidden="true"
>
{{ hover().label() }}
</div>
}
<input
class="player-controls__slider player-controls__slider--timeline"
[style.--slider-progress.%]="timeline().progress()"
type="range"
min="0"
[max]="timeline().duration()"
[value]="timeline().value()"
[disabled]="!canSeek()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_POSITION' | translate
"
[attr.aria-valuetext]="formatTime(timeline().value())"
(input)="scrubInput.emit($event)"
(change)="scrubCommit.emit($event)"
/>
</div>
} @else {
<div class="player-controls__timeline-spacer"></div>
}
@if (canSeek() && remaining(); as remaining) {
<span
class="player-controls__time player-controls__time--end"
data-test-id="player-controls-remaining-time"
>{{ remaining }}</span
>
} @else if (isLive()) {
<span
class="player-controls__live-badge"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate"
>
<span class="player-controls__live-dot" aria-hidden="true"></span>
LIVE
</span>
} @else {
<span class="player-controls__time player-controls__time--end">--:--</span>
}
@if (recordingStatus(); as status) {
<div
class="player-controls__recording-status"
[class.player-controls__recording-status--active]="recording()"
[attr.aria-live]="recording() ? 'off' : 'polite'"
>
<mat-icon>{{
recording() ? 'fiber_manual_record' : 'check_circle'
}}</mat-icon>
<span>{{ status }}</span>
</div>
}
@@ -0,0 +1,233 @@
// The dock's timeline row. Reads the `--pc-*` palette the controls host
// declares; the host is the `player-controls` size container queried below.
:host {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.player-controls__time {
flex: 0 0 auto;
color: var(--pc-text);
font-family: var(--pc-font-mono);
font-size: 14px;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.player-controls__time--end {
color: var(--pc-text-secondary);
}
.player-controls__timeline-spacer {
flex: 1 1 auto;
}
// Pointer target of the bar. Taller than the 6px track so it is easy to hit;
// the transparent range input on top owns dragging and the keyboard.
.player-controls__timeline-bar {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 18px;
cursor: pointer;
}
.player-controls__timeline-bar--disabled {
cursor: default;
}
.player-controls__timeline-track {
position: absolute;
top: 50%;
right: 0;
left: 0;
height: 6px;
transform: translateY(-50%);
}
// Segments sit at their exact time positions (`left` = start, `width` =
// share, minus the 3px gap at each segment's end), so a drawn boundary is
// where the linear seek input and the hover label change segment. A segment
// shorter than the gap simply collapses rather than shifting its neighbours.
.player-controls__timeline-segment {
position: absolute;
top: 0;
bottom: 0;
overflow: hidden;
border-radius: 3px;
background: var(--pc-track);
}
.player-controls__timeline-fill {
height: 100%;
background: var(--pc-accent-blue);
border-radius: inherit;
}
.player-controls__timeline-knob {
position: absolute;
top: 50%;
width: 14px;
height: 14px;
margin: -7px 0 0 -7px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 0 0 5px rgba(79, 142, 255, 0.3);
pointer-events: none;
transition: transform 120ms ease-out;
}
.player-controls__timeline-bar:hover .player-controls__timeline-knob,
.player-controls__timeline-bar:has(.player-controls__slider:focus-visible)
.player-controls__timeline-knob {
transform: scale(1.15);
}
.player-controls__timeline-marker {
position: absolute;
top: 50%;
width: 2px;
height: 12px;
margin: -6px 0 0 -1px;
border-radius: 1px;
background: #ffffff;
pointer-events: none;
}
.player-controls__timeline-label {
--hover-x: 0%;
position: absolute;
bottom: calc(100% + 2px);
// Centered on the pointer, but never past the bar's ends.
left: clamp(28px, var(--hover-x), calc(100% - 28px));
transform: translateX(-50%);
padding: 5px 8px;
color: var(--pc-text);
background: rgba(12, 16, 23, 0.85);
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.player-controls__recording-status {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--pc-text-dim);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
.player-controls__recording-status mat-icon {
flex: 0 0 auto;
width: 16px;
height: 16px;
font-size: 16px;
color: #ffffff;
}
.player-controls__recording-status span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-controls__recording-status--active {
color: var(--mat-sys-error, #ff5252);
font-weight: 700;
}
.player-controls__recording-status--active mat-icon {
color: var(--mat-sys-error, #ff5252);
}
.player-controls__live-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 4px;
color: #ffffff;
background: var(--mat-sys-error, #e53935);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.player-controls__live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: player-controls-live-pulse 1.6s ease-in-out infinite;
}
@keyframes player-controls-live-pulse {
50% {
opacity: 0.35;
}
}
// The range input is the interaction and accessibility layer over the drawn
// track: invisible and full-size. Its focus ring is drawn on the track
// instead, because an outline on a transparent element is invisible.
.player-controls__slider--timeline {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
}
.player-controls__slider--timeline:disabled {
cursor: default;
}
.player-controls__timeline-bar:has(.player-controls__slider:focus-visible)
.player-controls__timeline-track {
outline: 2px solid var(--pc-text);
outline-offset: 5px;
border-radius: 4px;
}
// Touch scrubbing needs a taller hit strip; the 6px track is unchanged.
@media (pointer: coarse) {
.player-controls__timeline-bar {
height: 28px;
}
}
// Keep in sync with `COMPACT_LAYOUT_MAX_WIDTH` in controls-layout.ts.
@container player-controls (max-width: 719px) {
:host {
gap: 10px;
}
.player-controls__time {
font-size: 13px;
}
.player-controls__timeline-track {
height: 5px;
}
.player-controls__timeline-knob {
width: 11px;
height: 11px;
margin: -5.5px 0 0 -5.5px;
box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.3);
}
}
@@ -0,0 +1,44 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { formatTime } from './controls-format.utils';
import type { ControlsTimeline } from './controls-timeline';
import type { ControlsTimelineHover } from './controls-timeline-hover';
/**
* The dock's timeline row: current time, the drawn segment track with its
* knob and hover label, the remaining time (or LIVE / `--:--`), and the
* recording status. Presentation only — scrub state lives in
* {@link ControlsTimeline}, and the range input's `input`/`change` events
* go back to the controls component, which owns reveal and seeking.
*/
@Component({
selector: 'app-player-timeline',
templateUrl: './player-timeline.component.html',
styleUrl: './player-timeline.component.scss',
imports: [MatIconModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'player-controls__timeline' },
})
export class PlayerTimelineComponent {
readonly timeline = input.required<ControlsTimeline>();
readonly hover = input.required<ControlsTimelineHover>();
/** The engine can seek at all; without it the track is not drawn. */
readonly seekable = input(false);
/** Seeking is possible right now. */
readonly canSeek = input(false);
readonly isLive = input(false);
/** `−7:03`, or null without a finite duration. */
readonly remaining = input<string | null>(null);
readonly recordingStatus = input<string | null>(null);
readonly recording = input(false);
readonly scrubInput = output<Event>();
readonly scrubCommit = output<Event>();
readonly formatTime = formatTime;
}
@@ -0,0 +1,52 @@
<button
type="button"
class="player-up-next__button"
data-test-id="player-controls-up-next"
(click)="selected.emit()"
[attr.aria-label]="
('EMBEDDED_MPV.PLAYER.UP_NEXT' | translate) +
': ' +
item().label +
(item().title ? ' – ' + item().title : '')
"
>
<span class="player-up-next__thumb" aria-hidden="true">
@if (thumbnail(); as src) {
<img
class="player-up-next__image"
[src]="src"
alt=""
loading="lazy"
(error)="onThumbnailError()"
/>
} @else {
<span class="player-up-next__placeholder">{{ item().label }}</span>
}
@if (item().progressPercent !== null) {
<span class="player-up-next__progress">
<span
class="player-up-next__progress-bar"
[style.width.%]="item().progressPercent"
></span>
</span>
}
</span>
<span class="player-up-next__meta">
<span class="player-up-next__eyebrow">
{{ 'EMBEDDED_MPV.PLAYER.UP_NEXT' | translate }}
·
{{
'EMBEDDED_MPV.PLAYER.UP_NEXT_IN'
| translate: { minutes: minutesLeft() }
}}
</span>
<span class="player-up-next__title">
{{ item().title || item().label }}
</span>
</span>
@if (!compact()) {
<mat-icon class="player-up-next__icon" aria-hidden="true"
>skip_next</mat-icon
>
}
</button>
@@ -0,0 +1,161 @@
// Glass card, bottom-right above the dock, inside the controls host (the
// `player-controls` container) whose `--pc-*` palette it reads. It does not
// fade with the controls: near the end of an episode it is worth seeing.
:host {
position: absolute;
right: 32px;
bottom: 136px;
z-index: 3;
display: block;
max-width: calc(100cqw - 64px);
pointer-events: auto;
animation: player-up-next-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
:host(.player-up-next--compact) {
right: 12px;
bottom: 92px;
max-width: calc(100cqw - 24px);
}
@keyframes player-up-next-in {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
:host {
animation: none;
}
}
.player-up-next__button {
display: flex;
align-items: center;
gap: 12px;
max-width: 320px;
padding: 8px 14px 8px 8px;
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: 14px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(18px) saturate(1.3);
cursor: pointer;
font: inherit;
text-align: left;
transition:
background 120ms ease-out,
transform 120ms ease-out;
}
.player-up-next__button:hover {
background: rgba(12, 16, 23, 0.84);
transform: translateY(-1px);
}
.player-up-next__button:focus-visible {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: 2px;
}
.player-up-next__thumb {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 88px;
height: 50px;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
.player-up-next__image {
width: 100%;
height: 100%;
object-fit: cover;
}
// No still: the episode label as a large numeral tile, so the card still
// looks designed without artwork.
.player-up-next__placeholder {
color: var(--pc-text-secondary, #9aa3b2);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.player-up-next__progress {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
background: rgba(255, 255, 255, 0.18);
}
.player-up-next__progress-bar {
display: block;
height: 100%;
background: var(--pc-accent-blue, #4f8eff);
}
.player-up-next__meta {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.player-up-next__eyebrow {
color: var(--pc-text-secondary, #9aa3b2);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.player-up-next__title {
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-up-next__icon {
flex: 0 0 auto;
width: 22px;
height: 22px;
font-size: 22px;
color: var(--pc-text-secondary, #9aa3b2);
}
:host(.player-up-next--compact) .player-up-next__button {
gap: 10px;
max-width: 240px;
padding: 5px 10px 5px 5px;
border-radius: 10px;
}
:host(.player-up-next--compact) .player-up-next__thumb {
width: 56px;
height: 32px;
border-radius: 6px;
}
:host(.player-up-next--compact) .player-up-next__eyebrow {
font-size: 9px;
}
:host(.player-up-next--compact) .player-up-next__title {
font-size: 11.5px;
}
@@ -0,0 +1,109 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlayerUpNextCardComponent } from './player-up-next-card.component';
describe('PlayerUpNextCardComponent', () => {
let fixture: ComponentFixture<PlayerUpNextCardComponent>;
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PlayerUpNextCardComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
UP_NEXT: 'Up next',
UP_NEXT_IN: 'in {{minutes}} min',
},
},
});
translate.use('en');
fixture = TestBed.createComponent(PlayerUpNextCardComponent);
fixture.componentRef.setInput('item', {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: 'https://img.example/still.jpg',
progressPercent: 40,
});
fixture.componentRef.setInput('minutesLeft', 7);
fixture.detectChanges();
});
afterEach(() => fixture.destroy());
it('renders the still, progress, countdown and title, and names itself', () => {
expect(query('.player-up-next__image')?.getAttribute('src')).toBe(
'https://img.example/still.jpg'
);
expect(query('.player-up-next__progress-bar')?.style.width).toBe('40%');
expect(
query('.player-up-next__eyebrow')?.textContent?.replace(/\s+/g, ' ')
).toContain('Up next · in 7 min');
expect(query('.player-up-next__title')?.textContent?.trim()).toBe(
'The Third One'
);
expect(
query('[data-test-id="player-controls-up-next"]')?.getAttribute(
'aria-label'
)
).toBe('Up next: S01E03 – The Third One');
expect(query('.player-up-next__icon')).not.toBeNull();
});
it('falls back to the label tile without a still and hides the icon when compact', () => {
fixture.componentRef.setInput('item', {
label: 'S02E01',
title: '',
thumbnailUrl: null,
progressPercent: null,
});
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
expect(query('.player-up-next__image')).toBeNull();
expect(query('.player-up-next__placeholder')?.textContent?.trim()).toBe(
'S02E01'
);
expect(query('.player-up-next__progress')).toBeNull();
expect(query('.player-up-next__title')?.textContent?.trim()).toBe(
'S02E01'
);
expect(query('.player-up-next__icon')).toBeNull();
expect(fixture.nativeElement.classList).toContain(
'player-up-next--compact'
);
});
it('falls back to the label tile when the still fails, and retries a new URL', () => {
query('.player-up-next__image')?.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(query('.player-up-next__image')).toBeNull();
expect(query('.player-up-next__placeholder')?.textContent?.trim()).toBe(
'S01E03'
);
fixture.componentRef.setInput('item', {
label: 'S01E04',
title: 'Four',
thumbnailUrl: 'https://img.example/other.jpg',
progressPercent: null,
});
fixture.detectChanges();
expect(query('.player-up-next__image')?.getAttribute('src')).toBe(
'https://img.example/other.jpg'
);
});
it('emits on click', () => {
const selected = jest.fn();
fixture.componentInstance.selected.subscribe(selected);
query('[data-test-id="player-controls-up-next"]')?.click();
expect(selected).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,45 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import type { PlayerUpNextItem } from './player-controls.model';
/**
* The "Up next" card in the player's bottom-right corner: thumbnail, the
* minutes left, the next episode's label and title. One click plays it
* through the host's ordinary next-episode path, so fullscreen survives.
*/
@Component({
selector: 'app-player-up-next-card',
templateUrl: './player-up-next-card.component.html',
styleUrl: './player-up-next-card.component.scss',
imports: [MatIconModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-up-next',
'[class.player-up-next--compact]': 'compact()',
},
})
export class PlayerUpNextCardComponent {
readonly item = input.required<PlayerUpNextItem>();
readonly minutesLeft = input.required<number>();
readonly compact = input(false);
readonly selected = output<void>();
/** The still that failed to load; a new URL gets its own attempt. */
private readonly failedThumbnail = signal<string | null>(null);
readonly thumbnail = computed(() => {
const url = this.item().thumbnailUrl;
return url && url !== this.failedThumbnail() ? url : null;
});
onThumbnailError(): void {
this.failedThumbnail.set(this.item().thumbnailUrl);
}
}
@@ -24,6 +24,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -25,6 +25,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -22,6 +22,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -115,6 +116,69 @@ describe('PortalInlinePlayerComponent up next rail', () => {
},
];
it('hands the episode after the playing one to the player as "up next"', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
fixture.detectChanges();
expect(component.playerUpNext()).toEqual({
label: 'S01E03',
title: 'Episode 3',
thumbnailUrl: null,
progressPercent: null,
});
// The last episode of the list has nothing after it.
fixture.componentRef.setInput('upNextEpisodes', [
{ ...upNextItems[1], isPlaying: true },
]);
fixture.detectChanges();
expect(component.playerUpNext()).toBeNull();
// Only episodes get a card: a movie never has a "next".
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
fixture.componentRef.setInput('playback', {
...seriesPlayback,
contentInfo: {
...seriesPlayback.contentInfo,
contentType: 'movie',
},
});
fixture.detectChanges();
expect(component.playerUpNext()).toBeNull();
});
it('plays the next season through the rail path once the season is over', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
const selected = jest.fn();
const next = jest.fn();
component.upNextEpisodeSelected.subscribe(selected);
component.nextEpisodeRequested.subscribe(next);
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: true,
canNext: false,
autoplayEnabled: false,
});
fixture.detectChanges();
component.onNextEpisodeRequested();
expect(selected).toHaveBeenCalledWith(upNextItems[1]);
expect(next).not.toHaveBeenCalled();
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: true,
canNext: true,
autoplayEnabled: false,
});
fixture.detectChanges();
component.onNextEpisodeRequested();
expect(next).toHaveBeenCalledTimes(1);
expect(selected).toHaveBeenCalledTimes(1);
});
/** Renders, then feeds a stage size as the ResizeObserver would. */
function renderWithStage(width: number, height: number): void {
fixture.detectChanges();
@@ -77,6 +77,7 @@
[streamUrl]="streamUrl()"
[title]="title()"
[mediaTitle]="playerMediaTitle()"
[upNext]="playerUpNext()"
[playback]="playback()"
[volume]="volume()"
[playerOverride]="playerOverride()"
@@ -25,6 +25,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -28,7 +28,7 @@ import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
import type { PlayerMediaTitle, PlayerUpNextItem } from '../player-controls';
import {
FULLSCREEN_CHANNEL_PANEL,
type FullscreenChannelPanelContext,
@@ -224,6 +224,28 @@ export class PortalInlinePlayerComponent {
readonly upNextRailItems = computed<UpNextRailItem[]>(
() => this.upNextEpisodes() ?? []
);
/** The rail entry after the playing one, across season boundaries. */
private readonly upNextRailNext = computed<UpNextRailItem | null>(() => {
const items = this.upNextEpisodes() ?? [];
const playing = items.findIndex((item) => item.isPlaying);
const next = playing >= 0 ? items[playing + 1] : undefined;
return next && this.playback()?.contentInfo?.contentType === 'episode'
? next
: null;
});
/** The episode after the playing one, for the controls' "Up next" card. */
readonly playerUpNext = computed<PlayerUpNextItem | null>(() => {
const next = this.upNextRailNext();
if (!next) {
return null;
}
return {
label: next.label,
title: next.title,
thumbnailUrl: next.thumbnailUrl,
progressPercent: next.progressPercent,
};
});
private readonly fullscreenEpisodePanelTemplate = viewChild<
TemplateRef<FullscreenChannelPanelContext>
@@ -323,6 +345,14 @@ export class PortalInlinePlayerComponent {
}
onNextEpisodeRequested(): void {
// Season-local navigation ends at a season's last episode; the Up
// next card can still offer the next season's first, which only
// the rail selection path knows how to play.
const next = this.upNextRailNext();
if (this.seriesNavigation()?.canNext === false && next) {
this.upNextEpisodeSelected.emit(next);
return;
}
this.nextEpisodeRequested.emit();
}
@@ -14,6 +14,7 @@
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -24,6 +24,7 @@ import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -77,6 +78,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -7,6 +7,7 @@
[options]="options"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
@@ -35,6 +36,7 @@
[channel]="$any(channel)"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -64,6 +66,7 @@
[channel]="$any(channel)"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -93,6 +96,7 @@
[playback]="resolvedPlayback()"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[recordingFolder]="recordingFolder()"
[recordingMetadata]="recordingMetadata()"
[seriesNavigation]="seriesNavigation()"
@@ -1,4 +1,3 @@
import { Component, input, output, signal } from '@angular/core';
import {
ComponentFixture,
DeferBlockBehavior,
@@ -13,12 +12,7 @@ import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
VideoPlayer,
type EmbeddedMpvSupport,
type RecordingStartMetadata,
type RecordingStoppedEvent,
} from '@iptvnator/shared/interfaces';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { ErrorDetails, ErrorTypes } from 'hls.js';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
@@ -35,7 +29,13 @@ import {
getDiagnosticMeta,
getDiagnosticTitleKey,
} from '../playback-diagnostic-panel/playback-diagnostic-view.util';
import { StubFullscreenChannelPanelComponent } from './web-player-view.spec-stubs';
import {
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
} from './web-player-view.spec-stubs';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -45,88 +45,6 @@ jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({
selector: 'app-vjs-player',
template: '<div data-test-id="stub-vjs-player"></div>',
})
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
selector: 'app-html-video-player',
template: '<div data-test-id="stub-html-player"></div>',
})
class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
selector: 'app-art-player',
template: '<div data-test-id="stub-art-player"></div>',
})
class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
selector: 'app-embedded-mpv-player',
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
})
class StubEmbeddedMpvPlayerComponent {
/** Read by the view's channel-panel gate (`EmbeddedMpvEngineReporter`). */
readonly support = signal<EmbeddedMpvSupport | null>(null);
readonly playback = input.required<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
describe('WebPlayerViewComponent', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
@@ -38,6 +38,7 @@ import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
} from '../player-controls';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
@@ -134,6 +135,8 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly playerOverride = input<VideoPlayer | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** Next episode for the shared controls' "Up next" card; series hosts only. */
readonly upNext = input<PlayerUpNextItem | null>(null);
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
@@ -20,6 +20,7 @@ export class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
@@ -41,6 +42,7 @@ export class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -63,6 +65,7 @@ export class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -95,6 +98,7 @@ export class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);