mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): Hybrid redesign of the shared player controls (#1709)
This commit is contained in:
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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "عرض الترجمة لاحقًا",
|
||||
|
||||
@@ -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": "ورّي الترجمة من بعد",
|
||||
|
||||
@@ -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": "Паказваць субтытры пазней",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Εμφάνιση υποτίτλων αργότερα",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "字幕を遅く表示",
|
||||
|
||||
@@ -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": "자막을 더 늦게 표시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показывать субтитры позже",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "延后显示字幕",
|
||||
|
||||
@@ -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": "延後顯示字幕",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
) {
|
||||
|
||||
+15
-16
@@ -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
+70
-32
@@ -144,7 +144,9 @@ describe('PlayerControlsComponent interactions', () => {
|
||||
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95);
|
||||
expect(component.feedback.current()?.label).toBe('95%');
|
||||
|
||||
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: -100 }));
|
||||
component.volumeInteractions.wheel(
|
||||
new WheelEvent('wheel', { deltaY: -100 })
|
||||
);
|
||||
expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1);
|
||||
});
|
||||
|
||||
@@ -170,7 +172,9 @@ describe('PlayerControlsComponent interactions', () => {
|
||||
});
|
||||
|
||||
it('keeps optimistic volume across capability and visibility changes', () => {
|
||||
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
|
||||
component.volumeInteractions.wheel(
|
||||
new WheelEvent('wheel', { deltaY: 100 })
|
||||
);
|
||||
expect(component.displayVolume()).toBe(0.95);
|
||||
|
||||
setState({ positionSeconds: 15, volume: 1 });
|
||||
@@ -187,7 +191,9 @@ describe('PlayerControlsComponent interactions', () => {
|
||||
});
|
||||
|
||||
it('reapplies persisted volume when the capability returns', () => {
|
||||
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
|
||||
component.volumeInteractions.wheel(
|
||||
new WheelEvent('wheel', { deltaY: 100 })
|
||||
);
|
||||
fake.commands.setVolume.mockClear();
|
||||
|
||||
setCapabilities({ volume: false });
|
||||
@@ -202,7 +208,9 @@ describe('PlayerControlsComponent interactions', () => {
|
||||
});
|
||||
|
||||
it('reconciles volume when the controller changes at the same value', () => {
|
||||
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
|
||||
component.volumeInteractions.wheel(
|
||||
new WheelEvent('wheel', { deltaY: 100 })
|
||||
);
|
||||
expect(component.displayVolume()).toBe(0.95);
|
||||
localStorage.removeItem('volume');
|
||||
|
||||
@@ -218,40 +226,40 @@ describe('PlayerControlsComponent interactions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('menu selections', () => {
|
||||
it('applies a speed preset and closes the menu', () => {
|
||||
describe('settings panel selections', () => {
|
||||
it('applies a speed preset from the chip-opened panel and keeps it open', () => {
|
||||
setCapabilities({ playbackSpeed: true });
|
||||
fixture.detectChanges();
|
||||
|
||||
query('[aria-label="EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED"]')?.click();
|
||||
query('[data-test-id="player-controls-speed-chip"]')?.click();
|
||||
fixture.detectChanges();
|
||||
expect(component.menus.speedOpen()).toBe(true);
|
||||
expect(component.menus.settingsOpen()).toBe(true);
|
||||
expect(component.menus.settingsFocus()).toBe('speed');
|
||||
|
||||
const preset = queryAll('.player-controls__track').find((item) =>
|
||||
item.textContent?.includes('1.5×')
|
||||
);
|
||||
const preset = queryAll(
|
||||
'[data-test-id="player-settings-speed"] .player-settings__seg-item'
|
||||
).find((item) => item.textContent?.includes('1.5×'));
|
||||
preset?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5);
|
||||
expect(component.menus.speedOpen()).toBe(false);
|
||||
expect(component.menus.settingsOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('applies an aspect preset and closes the menu', () => {
|
||||
it('applies an aspect preset from the panel', () => {
|
||||
setCapabilities({ aspectRatio: true });
|
||||
fixture.detectChanges();
|
||||
|
||||
query('[aria-label="EMBEDDED_MPV.PLAYER.ASPECT_RATIO"]')?.click();
|
||||
component.settings.open('aspect');
|
||||
fixture.detectChanges();
|
||||
|
||||
const preset = queryAll('.player-controls__track').find((item) =>
|
||||
item.textContent?.includes('16:9')
|
||||
);
|
||||
const preset = queryAll(
|
||||
'[data-test-id="player-settings-aspect"] .player-settings__seg-item'
|
||||
).find((item) => item.textContent?.includes('16:9'));
|
||||
preset?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9');
|
||||
expect(component.menus.aspectOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('disables subtitles via the Off entry', () => {
|
||||
@@ -262,22 +270,24 @@ describe('PlayerControlsComponent interactions', () => {
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
query('[aria-label="EMBEDDED_MPV.PLAYER.SUBTITLES"]')?.click();
|
||||
query('[data-test-id="player-controls-subtitle-chip"]')?.click();
|
||||
fixture.detectChanges();
|
||||
expect(component.menus.settingsFocus()).toBe('subtitles');
|
||||
|
||||
queryAll('.player-controls__track')[0]?.click();
|
||||
queryAll(
|
||||
'[data-test-id="player-settings-subtitles"] .player-settings__option'
|
||||
)[0]?.click();
|
||||
expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(-1);
|
||||
expect(component.menus.subtitleOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('opening one menu closes the others', () => {
|
||||
setCapabilities({ playbackSpeed: true, aspectRatio: true });
|
||||
setCapabilities({ playbackSpeed: true, volume: true });
|
||||
fixture.detectChanges();
|
||||
|
||||
component.toggleMenu('speed');
|
||||
component.toggleMenu('aspect');
|
||||
expect(component.menus.speedOpen()).toBe(false);
|
||||
expect(component.menus.aspectOpen()).toBe(true);
|
||||
component.settings.open('speed');
|
||||
component.toggleMenu('volume');
|
||||
expect(component.menus.settingsOpen()).toBe(false);
|
||||
expect(component.menus.volumeOpen()).toBe(true);
|
||||
expect(component.anyMenuOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -339,17 +349,25 @@ describe('PlayerControlsComponent interactions', () => {
|
||||
it('keeps the controls visible while a menu is open, hides after close', () => {
|
||||
setCapabilities({ playbackSpeed: true });
|
||||
fixture.detectChanges();
|
||||
component.toggleMenu('speed');
|
||||
component.settings.open('speed');
|
||||
fixture.detectChanges();
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
fixture.detectChanges();
|
||||
expect(component.controlsAreVisible()).toBe(true);
|
||||
|
||||
// A choice keeps the panel (and the controls) on screen…
|
||||
component.menuSelection.speed(1.25);
|
||||
fixture.detectChanges();
|
||||
jest.advanceTimersByTime(10000);
|
||||
fixture.detectChanges();
|
||||
expect(component.controlsAreVisible()).toBe(true);
|
||||
|
||||
// …closing it re-arms the auto-hide.
|
||||
component.settings.close();
|
||||
fixture.detectChanges();
|
||||
jest.advanceTimersByTime(10000);
|
||||
fixture.detectChanges();
|
||||
expect(component.controlsAreVisible()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -403,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
+485
@@ -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);
|
||||
|
||||
+14
-9
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user