From be217d5cf5a2fcf06c3071b6374e1b92123e14c3 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:32:55 +0200 Subject: [PATCH] feat(playback): Hybrid redesign of the shared player controls (#1709) --- .changes/playback-hybrid-player-controls.md | 10 + .changes/playback-player-settings-panel.md | 11 + .changes/playback-timeline-segments-model.md | 8 + .changes/playback-up-next-card.md | 9 + apps/electron-backend-e2e/src/settings.e2e.ts | 12 + apps/web-e2e/src/player-settings-panel.e2e.ts | 217 +++ .../web-e2e/src/xtream-series-playback.e2e.ts | 48 + apps/web/src/assets/i18n/ar.json | 4 + apps/web/src/assets/i18n/ary.json | 4 + apps/web/src/assets/i18n/by.json | 4 + apps/web/src/assets/i18n/de.json | 4 + apps/web/src/assets/i18n/el.json | 4 + apps/web/src/assets/i18n/en.json | 4 + apps/web/src/assets/i18n/es.json | 4 + apps/web/src/assets/i18n/fr.json | 4 + apps/web/src/assets/i18n/hu.json | 4 + apps/web/src/assets/i18n/it.json | 4 + apps/web/src/assets/i18n/ja.json | 4 + apps/web/src/assets/i18n/ko.json | 4 + apps/web/src/assets/i18n/nl.json | 4 + apps/web/src/assets/i18n/pl.json | 4 + apps/web/src/assets/i18n/pt.json | 4 + apps/web/src/assets/i18n/ru.json | 4 + apps/web/src/assets/i18n/tr.json | 4 + apps/web/src/assets/i18n/zh.json | 4 + apps/web/src/assets/i18n/zhtw.json | 4 + docs/architecture/iptvnator-ui-guidelines.md | 3 +- docs/architecture/player-controls-contract.md | 263 +++- .../lib/art-player/art-player.component.html | 1 + .../lib/art-player/art-player.component.ts | 2 + .../embedded-mpv-player.component.html | 1 + .../embedded-mpv-player.component.ts | 6 +- .../html-video-player.component.html | 1 + .../html-video-player.component.ts | 2 + .../controls-format.utils.spec.ts | 15 + .../player-controls/controls-format.utils.ts | 23 + .../player-controls/controls-layout.spec.ts | 127 ++ .../lib/player-controls/controls-layout.ts | 83 ++ .../controls-menu-selection.spec.ts | 70 +- .../controls-menu-selection.ts | 37 +- .../controls-menu-state.spec.ts | 144 +- .../player-controls/controls-menu-state.ts | 86 +- .../controls-settings-groups.ts | 47 + .../player-controls/controls-settings.spec.ts | 194 +++ .../lib/player-controls/controls-settings.ts | 164 +++ .../controls-timeline-hover.spec.ts | 97 ++ .../controls-timeline-hover.ts | 107 ++ .../controls-timeline-segments.spec.ts | 150 ++ .../controls-timeline-segments.ts | 128 ++ .../lib/player-controls/controls-timeline.ts | 30 +- .../player-controls/controls-up-next.spec.ts | 142 ++ .../lib/player-controls/controls-up-next.ts | 69 + .../controls-volume-interactions.ts | 8 +- ...player-controls.component.contract.spec.ts | 31 +- .../player-controls.component.dock.spec.ts | 567 ++++++++ .../player-controls.component.html | 1249 ++++++----------- ...er-controls.component.interactions.spec.ts | 102 +- .../player-controls.component.scss | 798 ++++++----- ...-controls.component.settings-panel.spec.ts | 485 +++++++ .../player-controls.component.spec.ts | 45 +- ...ntrols.component.subtitle-settings.spec.ts | 23 +- .../player-controls.component.surface.spec.ts | 8 +- ...player-controls.component.timeline.spec.ts | 2 +- .../player-controls.component.ts | 86 +- .../player-controls/player-controls.model.ts | 32 +- .../player-settings-panel.component.html | 439 ++++++ .../player-settings-panel.component.scss | 448 ++++++ .../player-settings-panel.component.ts | 162 +++ .../player-timeline.component.html | 98 ++ .../player-timeline.component.scss | 233 +++ .../player-timeline.component.ts | 44 + .../player-up-next-card.component.html | 52 + .../player-up-next-card.component.scss | 161 +++ .../player-up-next-card.component.spec.ts | 109 ++ .../player-up-next-card.component.ts | 45 + ...portal-inline-player-episode-panel.spec.ts | 1 + .../portal-inline-player-sources.spec.ts | 1 + .../portal-inline-player-up-next.spec.ts | 64 + .../portal-inline-player.component.html | 1 + .../portal-inline-player.component.spec.ts | 1 + .../portal-inline-player.component.ts | 32 +- .../lib/vjs-player/vjs-player.component.html | 1 + .../lib/vjs-player/vjs-player.component.ts | 2 + .../web-player-view.component.html | 4 + .../web-player-view.component.spec.ts | 98 +- .../web-player-view.component.ts | 3 + .../web-player-view.spec-stubs.ts | 4 + 87 files changed, 6152 insertions(+), 1670 deletions(-) create mode 100644 .changes/playback-hybrid-player-controls.md create mode 100644 .changes/playback-player-settings-panel.md create mode 100644 .changes/playback-timeline-segments-model.md create mode 100644 .changes/playback-up-next-card.md create mode 100644 apps/web-e2e/src/player-settings-panel.e2e.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-layout.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-settings.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-up-next.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html create mode 100644 libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss create mode 100644 libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-timeline.component.html create mode 100644 libs/ui/playback/src/lib/player-controls/player-timeline.component.scss create mode 100644 libs/ui/playback/src/lib/player-controls/player-timeline.component.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html create mode 100644 libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss create mode 100644 libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts diff --git a/.changes/playback-hybrid-player-controls.md b/.changes/playback-hybrid-player-controls.md new file mode 100644 index 000000000..9d2fb0174 --- /dev/null +++ b/.changes/playback-hybrid-player-controls.md @@ -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. diff --git a/.changes/playback-player-settings-panel.md b/.changes/playback-player-settings-panel.md new file mode 100644 index 000000000..0ab266108 --- /dev/null +++ b/.changes/playback-player-settings-panel.md @@ -0,0 +1,11 @@ +--- +type: feature +area: playback +highlight: Player settings panel +--- + +Audio, subtitles, quality, speed and aspect ratio now live in one settings +panel behind a tune button. Wide players show subtitle and speed chips with +their current value and slide the panel in beside the video; small players +get a bottom sheet, and the tune button shows colored dots for anything +turned on or changed. diff --git a/.changes/playback-timeline-segments-model.md b/.changes/playback-timeline-segments-model.md new file mode 100644 index 000000000..cbcd8eff1 --- /dev/null +++ b/.changes/playback-timeline-segments-model.md @@ -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. diff --git a/.changes/playback-up-next-card.md b/.changes/playback-up-next-card.md new file mode 100644 index 000000000..670036029 --- /dev/null +++ b/.changes/playback-up-next-card.md @@ -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. diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 156f0a71b..1ecbb6e18 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -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); diff --git a/apps/web-e2e/src/player-settings-panel.e2e.ts b/apps/web-e2e/src/player-settings-panel.e2e.ts new file mode 100644 index 000000000..0f399a397 --- /dev/null +++ b/apps/web-e2e/src/player-settings-panel.e2e.ts @@ -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 { + const clip = readFileSync( + join(__dirname, 'fixtures/playback/episode.webm') + ); + await page.route(`${FIXTURE_HOST}/**`, async (route) => { + const range = /^bytes=(\d*)-(\d*)$/.exec( + route.request().headers()['range'] ?? '' + ); + const last = clip.length - 1; + const start = range?.[1] + ? Number(range[1]) + : range?.[2] + ? Math.max(0, clip.length - Number(range[2])) + : 0; + const end = + range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last; + await route.fulfill({ + status: range ? 206 : 200, + headers: { + 'content-type': 'video/webm', + 'accept-ranges': 'bytes', + 'content-length': String(end - start + 1), + ...(range + ? { + 'content-range': `bytes ${start}-${end}/${clip.length}`, + } + : {}), + }, + body: clip.subarray(start, end + 1), + }); + }); +} + +async function selectHtml5Player(page: Page): Promise { + await page.goto('/workspace/settings/playback'); + const select = page.locator('[data-test-id="select-video-player"]'); + await expect(select).toBeVisible(); + const previous = await select.innerText(); + await select.click(); + await page + .getByRole('option', { name: 'HTML5 video player', exact: true }) + .click(); + if (!previous.includes('HTML5 video player')) { + const saveButton = page.getByRole('button', { name: 'Save changes' }); + await saveButton.click(); + await expect(saveButton).toBeHidden(); + } +} + +async function importPlaylist(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); + await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST); + await Promise.all([ + page.waitForURL(/\/workspace\/playlists\/.+\/all$/), + dialog.getByRole('button', { name: 'Import', exact: true }).click(), + ]); + await expect(page.getByText('1 channels')).toBeVisible(); +} + +async function startClip(page: Page) { + await page + .locator('[data-test-id="channel-item"]') + .filter({ hasText: 'Settings Clip' }) + .click(); + const view = page.locator('app-web-player-view'); + const video = view.locator('video'); + await expect + .poll(() => + video.evaluate( + (el: HTMLVideoElement) => + Number.isFinite(el.duration) && el.duration > 5 + ) + ) + .toBe(true); + // Reveal the dock and keep it revealed for the assertions below. + await view.hover(); + const controls = view.locator('app-player-controls'); + await controls.getByRole('button', { name: 'Pause', exact: true }).click(); + return { view, video, controls }; +} + +test('@web @playback settings panel opens from the speed chip and applies in place', async ({ + page, +}) => { + test.setTimeout(90_000); + // 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(); +}); diff --git a/apps/web-e2e/src/xtream-series-playback.e2e.ts b/apps/web-e2e/src/xtream-series-playback.e2e.ts index ad1d0d432..76c610080 100644 --- a/apps/web-e2e/src/xtream-series-playback.e2e.ts +++ b/apps/web-e2e/src/xtream-series-playback.e2e.ts @@ -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, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index c87685b7b..fff519f94 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "عرض الترجمة لاحقًا", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 08a163818..567d33401 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "ورّي الترجمة من بعد", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 39b78647d..ef639b363 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Паказваць субтытры пазней", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index dea5d2dcb..0a0909a99 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 2ecfc73b5..020bb8240 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Εμφάνιση υποτίτλων αργότερα", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index bd8993e26..85e0c5b1c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 4da5c119f..89a8e80ca 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 7922e0a01..4fe6fde97 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index c2e21bb22..4a2ef1e3c 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index b67382ace..cbc932b07 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 81686f119..9535e8a7c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "字幕を遅く表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 828faf2cd..25cfb076c 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "자막을 더 늦게 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index ffffce6d0..cbaf49f3d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index de3af3d84..b66656214 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 7d7fd2d2a..651645746 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 8fcfc7243..d34cb2520 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Показывать субтитры позже", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 76e843e94..79b4d4fae 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index cd2697e87..5a37e0824 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "延后显示字幕", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 7f5adcdba..080a98ff4 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "延後顯示字幕", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 9ed67dbe5..7ab521618 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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, diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index aa8762eed..a4d9c5eb4 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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 `` 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 diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.html b/libs/ui/playback/src/lib/art-player/art-player.component.html index 793e8dede..918f3a631 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.html +++ b/libs/ui/playback/src/lib/art-player/art-player.component.html @@ -12,6 +12,7 @@ [playerSurface]="playerRoot" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 1dc49464a..986ff3923 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -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(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index 0e2d44191..aff35a474 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -103,6 +103,7 @@ [playerSurface]="playerSurface()" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="showControls()" [shortcutsEnabled]="sharedShortcutsEnabled()" (previousEpisodeRequested)="requestPreviousEpisode()" diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index ed2fef2da..c768ba534 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -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(null); readonly seriesNavigation = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */ readonly fullscreenTarget = input(null); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html index 2e08db2f8..e554aaa6f 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html @@ -12,6 +12,7 @@ [playerSurface]="playerRoot" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 9cbb37f06..8de5124a6 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -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(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); @Output() timeUpdate = new EventEmitter<{ diff --git a/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts index 95513efd2..30963461e 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts @@ -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); diff --git a/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts b/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts index 397611283..86b104650 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts new file mode 100644 index 000000000..e5dfe5700 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-layout.ts b/libs/ui/playback/src/lib/player-controls/controls-layout.ts new file mode 100644 index 000000000..9a8ac6574 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-layout.ts @@ -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('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(); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts index c692ec3e4..e4fdd2488 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts @@ -1,12 +1,8 @@ import { ControlsMenuSelection } from './controls-menu-selection'; -import { ControlsMenuState } from './controls-menu-state'; -import { ControlsVisibility } from './controls-visibility'; import type { PlayerControlsCommands } from './player-controls.model'; describe('ControlsMenuSelection', () => { let commands: jest.Mocked; - let menus: ControlsMenuState; - let visibility: ControlsVisibility; let revealSticky: jest.Mock; let selection: ControlsMenuSelection; @@ -27,75 +23,35 @@ describe('ControlsMenuSelection', () => { toggleRecording: jest.fn(), togglePictureInPicture: jest.fn(), }; - menus = new ControlsMenuState(); - visibility = new ControlsVisibility(() => false); - jest.spyOn(visibility, 'scheduleHide'); revealSticky = jest.fn(); selection = new ControlsMenuSelection({ commands: () => commands, - menus, - visibility, revealSticky, }); }); - it('toggles a menu through the menu state', () => { - selection.toggle('audio'); - expect(menus.audioOpen()).toBe(true); - - selection.toggle('audio'); - expect(menus.audioOpen()).toBe(false); - }); - - it('selects an audio track: reveal sticky, command, close menu, reschedule hide', () => { - menus.open('audio'); + it('selects an audio track: reveal sticky, then the command', () => { selection.audioTrack(3); expect(revealSticky).toHaveBeenCalledTimes(1); expect(commands.setAudioTrack).toHaveBeenCalledWith(3); - expect(menus.audioOpen()).toBe(false); - expect(visibility.scheduleHide).toHaveBeenCalledTimes(1); }); - it('selects a subtitle track and closes the subtitle menu', () => { - menus.open('subtitle'); - selection.subtitleTrack(-1); + it.each([ + ['subtitleTrack', -1, 'setSubtitleTrack'], + ['qualityLevel', -1, 'setQualityLevel'], + ['speed', 1.5, 'setPlaybackSpeed'], + ['aspect', '16:9', 'setAspectRatio'], + ] as const)('routes %s to the engine command', (method, value, command) => { + (selection[method] as (arg: unknown) => void)(value); - expect(commands.setSubtitleTrack).toHaveBeenCalledWith(-1); - expect(menus.subtitleOpen()).toBe(false); - expect(visibility.scheduleHide).toHaveBeenCalled(); + expect(revealSticky).toHaveBeenCalledTimes(1); + expect(commands[command]).toHaveBeenCalledWith(value); }); - it('selects a quality level and closes the quality menu', () => { - menus.open('quality'); - selection.qualityLevel(-1); + it('opens the subtitle file picker', () => { + selection.externalSubtitle(); - expect(commands.setQualityLevel).toHaveBeenCalledWith(-1); - expect(menus.qualityOpen()).toBe(false); - expect(visibility.scheduleHide).toHaveBeenCalled(); - }); - - it('applies a playback speed and closes the speed menu', () => { - menus.open('speed'); - selection.speed(1.5); - - expect(commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5); - expect(menus.speedOpen()).toBe(false); - }); - - it('applies an aspect ratio and closes the aspect menu', () => { - menus.open('aspect'); - selection.aspect('16:9'); - - expect(commands.setAspectRatio).toHaveBeenCalledWith('16:9'); - expect(menus.aspectOpen()).toBe(false); - }); - - it('only closes the menu the selection belongs to', () => { - menus.open('volume'); - selection.speed(2); - - // Closing 'speed' must not blindly close every popover. - expect(menus.volumeOpen()).toBe(true); + expect(commands.addExternalSubtitleFile).toHaveBeenCalledTimes(1); }); }); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts index 3a57ad9cf..e18c39127 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts @@ -1,58 +1,47 @@ -import type { ControlsMenuState } from './controls-menu-state'; -import type { ControlsVisibility } from './controls-visibility'; import type { PlayerControlsCommands } from './player-controls.model'; -type MenuKey = 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect'; - export interface MenuSelectionDeps { commands: () => PlayerControlsCommands; - menus: ControlsMenuState; - visibility: ControlsVisibility; - /** Reveal without rescheduling the auto-hide (kept open while choosing). */ + /** Reveal without rescheduling the auto-hide (the panel stays open). */ revealSticky: () => void; } /** - * Owns track/speed/aspect menu selection: reveal-without-hide, run the command, - * close the menu, then reschedule the auto-hide. Keeps the component lean while - * the template still binds to thin delegating methods. + * Owns track/quality/speed/aspect selection from the settings panel: reveal + * without hiding, then run the command. The panel deliberately stays open — + * a choice applies immediately and is judged against the running video, so + * closing it on every click would cost a reopen per comparison. */ export class ControlsMenuSelection { constructor(private readonly deps: MenuSelectionDeps) {} - toggle(menu: MenuKey): void { - this.deps.menus.toggle(menu); - } - audioTrack(trackId: number): void { - this.apply('audio', (c) => c.setAudioTrack(trackId)); + this.apply((c) => c.setAudioTrack(trackId)); } subtitleTrack(trackId: number): void { - this.apply('subtitle', (c) => c.setSubtitleTrack(trackId)); + this.apply((c) => c.setSubtitleTrack(trackId)); } - /** Opens the engine's subtitle file picker and closes the popover. */ + /** Opens the engine's subtitle file picker. */ externalSubtitle(): void { - this.apply('subtitle', (c) => c.addExternalSubtitleFile()); + this.apply((c) => c.addExternalSubtitleFile()); } qualityLevel(levelId: number): void { - this.apply('quality', (c) => c.setQualityLevel(levelId)); + this.apply((c) => c.setQualityLevel(levelId)); } speed(value: number): void { - this.apply('speed', (c) => c.setPlaybackSpeed(value)); + this.apply((c) => c.setPlaybackSpeed(value)); } aspect(value: string): void { - this.apply('aspect', (c) => c.setAspectRatio(value)); + this.apply((c) => c.setAspectRatio(value)); } - private apply(menu: MenuKey, run: (c: PlayerControlsCommands) => void): void { + private apply(run: (c: PlayerControlsCommands) => void): void { this.deps.revealSticky(); run(this.deps.commands()); - this.deps.menus.close(menu); - this.deps.visibility.scheduleHide(); } } diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts index 7116e8c09..be43cab5c 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts @@ -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); + }); }); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts index 387d0df5c..6568b10ef 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts @@ -1,18 +1,15 @@ import { computed, signal } from '@angular/core'; +import { + anySettingsGroupAvailable, + getSettingsGroupAvailability, + type SettingsGroup, +} from './controls-settings-groups'; import type { PlayerControlsCapabilities, PlayerControlsState, } from './player-controls.model'; -const CONTROL_MENUS = [ - 'volume', - 'audio', - 'subtitle', - 'quality', - 'speed', - 'aspect', - 'stats', -] as const; +const CONTROL_MENUS = ['volume', 'settings', 'stats'] as const; export type ControlsMenu = (typeof CONTROL_MENUS)[number]; export type ControlsMenuAvailability = Readonly>; @@ -24,49 +21,30 @@ function getControlsMenuAvailability( ): ControlsMenuAvailability { return { volume: showControls && capabilities.volume, - audio: + settings: showControls && - capabilities.audioTracks && - state.audioTracks.length > 1, - // External subtitle loading keeps the menu reachable with an empty - // track list — the "Load subtitle file…" action is how the first - // track appears. - subtitle: - showControls && - ((capabilities.subtitles && state.subtitleTracks.length > 0) || - capabilities.externalSubtitles), - quality: - showControls && - capabilities.qualityLevels && - state.qualityLevels.length > 1, - speed: showControls && capabilities.playbackSpeed, - aspect: showControls && capabilities.aspectRatio, + anySettingsGroupAvailable( + getSettingsGroupAvailability(capabilities, state) + ), stats: showControls && capabilities.streamStats, }; } /** * Tracks which menu/popover is currently open and exposes individual signals - * the template binds to. Only one menu can be open at a time. + * the template binds to. Only one menu can be open at a time. The settings + * panel is one menu: every track, quality, speed and aspect choice lives + * inside it, and `settingsFocus` names the group it was opened for (a chip + * click), so the panel can bring that group into view. */ export class ControlsMenuState { readonly volumeOpen = signal(false); - readonly audioOpen = signal(false); - readonly subtitleOpen = signal(false); - readonly qualityOpen = signal(false); - readonly speedOpen = signal(false); - readonly aspectOpen = signal(false); + readonly settingsOpen = signal(false); readonly statsOpen = signal(false); + readonly settingsFocus = signal(null); readonly anyOpen = computed( - () => - this.volumeOpen() || - this.audioOpen() || - this.subtitleOpen() || - this.qualityOpen() || - this.speedOpen() || - this.aspectOpen() || - this.statsOpen() + () => this.volumeOpen() || this.settingsOpen() || this.statsOpen() ); toggle(menu: ControlsMenu): void { @@ -84,18 +62,24 @@ export class ControlsMenuState { this.signalFor(menu).set(true); } + /** Opens the settings panel on one group (or wherever it was). */ + openSettings(group: SettingsGroup | null = null): void { + this.open('settings'); + this.settingsFocus.set(group); + } + close(menu: ControlsMenu): void { this.signalFor(menu).set(false); + if (menu === 'settings') { + this.settingsFocus.set(null); + } } closeAll(): void { this.volumeOpen.set(false); - this.audioOpen.set(false); - this.subtitleOpen.set(false); - this.qualityOpen.set(false); - this.speedOpen.set(false); - this.aspectOpen.set(false); + this.settingsOpen.set(false); this.statsOpen.set(false); + this.settingsFocus.set(null); } reconcile(availability: ControlsMenuAvailability): boolean { @@ -103,7 +87,7 @@ export class ControlsMenuState { for (const menu of CONTROL_MENUS) { const open = this.signalFor(menu); if (open() && !availability[menu]) { - open.set(false); + this.close(menu); changed = true; } } @@ -124,16 +108,8 @@ export class ControlsMenuState { switch (menu) { case 'volume': return this.volumeOpen; - case 'audio': - return this.audioOpen; - case 'subtitle': - return this.subtitleOpen; - case 'quality': - return this.qualityOpen; - case 'speed': - return this.speedOpen; - case 'aspect': - return this.aspectOpen; + case 'settings': + return this.settingsOpen; case 'stats': return this.statsOpen; } diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts b/libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts new file mode 100644 index 000000000..c60d281c7 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts @@ -0,0 +1,47 @@ +import type { + PlayerControlsCapabilities, + PlayerControlsState, +} from './player-controls.model'; + +/** The groups of the settings panel, in the order they are rendered. */ +export const SETTINGS_GROUPS = [ + 'audio', + 'subtitles', + 'quality', + 'speed', + 'aspect', +] as const; + +export type SettingsGroup = (typeof SETTINGS_GROUPS)[number]; +export type SettingsGroupAvailability = Readonly< + Record +>; + +/** + * Which settings groups the current engine and stream offer. A group with + * nothing to choose from is not rendered, exactly like the popover buttons + * it replaces were not. + */ +export function getSettingsGroupAvailability( + capabilities: PlayerControlsCapabilities, + state: PlayerControlsState +): SettingsGroupAvailability { + return { + audio: capabilities.audioTracks && state.audioTracks.length > 1, + // External subtitle loading keeps the group reachable with an empty + // track list — the "Load subtitle file…" action is how the first + // track appears. + subtitles: + (capabilities.subtitles && state.subtitleTracks.length > 0) || + capabilities.externalSubtitles, + quality: capabilities.qualityLevels && state.qualityLevels.length > 1, + speed: capabilities.playbackSpeed, + aspect: capabilities.aspectRatio, + }; +} + +export function anySettingsGroupAvailable( + availability: SettingsGroupAvailability +): boolean { + return SETTINGS_GROUPS.some((group) => availability[group]); +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts new file mode 100644 index 000000000..6b00bf2c3 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts @@ -0,0 +1,194 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ControlsMenuState } from './controls-menu-state'; +import { ControlsSettings } from './controls-settings'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, +} from './player-controls.model'; + +describe('ControlsSettings', () => { + let state: WritableSignal; + let capabilities: WritableSignal; + let showControls: WritableSignal; + let menus: ControlsMenuState; + let commands: jest.Mocked; + let reveal: jest.Mock; + let settings: ControlsSettings; + + const setState = (overrides: Partial) => + state.set({ ...createEmptyControlsState(), ...overrides }); + const setCapabilities = (overrides: Partial) => + capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + beforeEach(() => { + state = signal(createEmptyControlsState()); + capabilities = signal({ ...DEFAULT_PLAYER_CAPABILITIES }); + showControls = signal(true); + menus = new ControlsMenuState(); + commands = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), + setQualityLevel: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + togglePictureInPicture: jest.fn(), + }; + reveal = jest.fn(); + settings = new ControlsSettings({ + state, + capabilities, + showControls, + menus, + commands: () => commands, + reveal, + }); + }); + + it('is unavailable without any group and cannot open', () => { + expect(settings.available()).toBe(false); + + settings.open('speed'); + settings.toggle(); + + expect(menus.settingsOpen()).toBe(false); + expect(settings.isOpen()).toBe(false); + expect(reveal).not.toHaveBeenCalled(); + }); + + it('opens on a group with a sticky reveal and toggles closed', () => { + setCapabilities({ playbackSpeed: true }); + + settings.open('speed'); + expect(settings.isOpen()).toBe(true); + expect(settings.focusGroup()).toBe('speed'); + expect(reveal).toHaveBeenCalledWith({ scheduleHide: false }); + + settings.toggle(); + expect(settings.isOpen()).toBe(false); + expect(settings.focusGroup()).toBeNull(); + }); + + it('is hidden with the controls even while the menu flag is set', () => { + setCapabilities({ playbackSpeed: true }); + settings.open(); + showControls.set(false); + + expect(settings.available()).toBe(false); + expect(settings.isOpen()).toBe(false); + }); + + it('derives on/modified state and the tune dots', () => { + setCapabilities({ + audioTracks: true, + subtitles: true, + playbackSpeed: true, + aspectRatio: true, + qualityLevels: true, + }); + setState({ + audioTracks: [ + { id: 1, label: 'English', selected: true }, + { id: 2, label: 'German', selected: false }, + ], + subtitleTracks: [{ id: 5, label: 'Russian', selected: false }], + qualityLevels: [ + { id: 0, label: '1080p', selected: false }, + { id: 1, label: '720p', selected: false }, + ], + qualityAutoEnabled: true, + }); + expect(settings.dots()).toEqual({ cyan: false, violet: false }); + expect(settings.hasDots()).toBe(false); + expect(settings.subtitleLabel()).toBeNull(); + expect(settings.speedLabel()).toBe('1×'); + + setState({ + audioTracks: [ + { id: 1, label: 'English', selected: false }, + { id: 2, label: 'German', selected: true }, + ], + subtitleTracks: [{ id: 5, label: 'Russian', selected: true }], + subtitlesEnabled: true, + qualityLevels: [ + { id: 0, label: '1080p', selected: true }, + { id: 1, label: '720p', selected: false }, + ], + qualityAutoEnabled: false, + playbackSpeed: 1.25, + aspectRatio: '16:9', + }); + expect(settings.subtitlesOn()).toBe(true); + expect(settings.subtitleLabel()).toBe('Russian'); + expect(settings.audioModified()).toBe(true); + expect(settings.qualityModified()).toBe(true); + expect(settings.speedModified()).toBe(true); + expect(settings.speedLabel()).toBe('1.25×'); + expect(settings.aspectModified()).toBe(true); + expect(settings.dots()).toEqual({ cyan: true, violet: true }); + }); + + it('treats the first aspect preset as the unmodified default', () => { + setCapabilities({ aspectRatio: true }); + setState({ + aspectRatio: 'auto', + aspectPresets: [ + { value: 'auto', label: 'Auto' }, + { value: '16:9', label: '16:9' }, + ], + }); + expect(settings.defaultAspect()).toBe('auto'); + expect(settings.aspectModified()).toBe(false); + }); + + it('toggles subtitles from the chip without opening the panel', () => { + setCapabilities({ subtitles: true }); + setState({ + subtitleTracks: [ + { id: 7, label: 'English', selected: false }, + { id: 8, label: 'German', selected: false }, + ], + }); + const event = { preventDefault: jest.fn() } as unknown as Event; + + settings.onSubtitleChipContextMenu(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(commands.setSubtitleTrack).toHaveBeenCalledWith(7); + expect(menus.settingsOpen()).toBe(false); + + setState({ + subtitleTracks: [{ id: 7, label: 'English', selected: true }], + subtitlesEnabled: true, + }); + settings.toggleSubtitles(); + expect(commands.setSubtitleTrack).toHaveBeenLastCalledWith(-1); + }); + + it('opens the subtitles group instead when there is no track to turn on', () => { + setCapabilities({ externalSubtitles: true }); + + settings.toggleSubtitles(); + + expect(commands.setSubtitleTrack).not.toHaveBeenCalled(); + expect(settings.isOpen()).toBe(true); + expect(settings.focusGroup()).toBe('subtitles'); + }); + + it('ignores the subtitle toggle without a subtitle group', () => { + settings.toggleSubtitles(); + expect(commands.setSubtitleTrack).not.toHaveBeenCalled(); + expect(reveal).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings.ts b/libs/ui/playback/src/lib/player-controls/controls-settings.ts new file mode 100644 index 000000000..a7fb26361 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-settings.ts @@ -0,0 +1,164 @@ +import { Signal, computed } from '@angular/core'; +import { speedLabel } from './controls-format.utils'; +import type { ControlsMenuState } from './controls-menu-state'; +import { + anySettingsGroupAvailable, + getSettingsGroupAvailability, + type SettingsGroup, +} from './controls-settings-groups'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, +} from './player-controls.model'; + +export interface ControlsSettingsDeps { + state: Signal; + capabilities: Signal; + showControls: Signal; + menus: ControlsMenuState; + commands: () => PlayerControlsCommands; + reveal: (options?: { scheduleHide?: boolean }) => void; +} + +/** Colored state dots on the compact `tune` button. */ +export interface SettingsStateDots { + /** Something is on: subtitles enabled, a non-default audio track. */ + cyan: boolean; + /** A value was changed: speed, aspect ratio, manual quality. */ + violet: boolean; +} + +/** + * State behind the `tune` button, its chips and the settings panel: which + * groups exist, what is on or modified (the color-as-state system), and the + * open/close/toggle transitions. The panel itself only renders. + */ +export class ControlsSettings { + constructor(private readonly deps: ControlsSettingsDeps) {} + + readonly groups = computed(() => + getSettingsGroupAvailability( + this.deps.capabilities(), + this.deps.state() + ) + ); + + readonly available = computed( + () => + this.deps.showControls() && anySettingsGroupAvailable(this.groups()) + ); + + readonly isOpen = computed( + () => this.available() && this.deps.menus.settingsOpen() + ); + + readonly focusGroup = 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(() => ({ + cyan: this.subtitlesOn() || this.audioModified(), + violet: + this.speedModified() || + this.aspectModified() || + this.qualityModified(), + })); + + readonly hasDots = computed(() => this.dots().cyan || this.dots().violet); + + open(group: SettingsGroup | null = null): void { + if (!this.available()) { + return; + } + this.deps.reveal({ scheduleHide: false }); + this.deps.menus.openSettings(group); + } + + toggle(): void { + if (!this.available()) { + return; + } + this.deps.reveal(); + this.deps.menus.toggle('settings'); + } + + close(): void { + this.deps.menus.close('settings'); + this.deps.reveal(); + } + + /** + * Secondary action of the subtitle chip (right-click, long-press): + * flip subtitles without opening the panel. With no embedded track to + * turn on, the panel opens instead so the file loader is reachable. + */ + onSubtitleChipContextMenu(event: Event): void { + event.preventDefault(); + this.toggleSubtitles(); + } + + toggleSubtitles(): void { + if (!this.groups().subtitles) { + return; + } + const state = this.deps.state(); + if (state.subtitlesEnabled) { + this.deps.reveal(); + this.deps.commands().setSubtitleTrack(-1); + return; + } + const first = state.subtitleTracks[0]; + if (!first) { + this.open('subtitles'); + return; + } + this.deps.reveal(); + this.deps.commands().setSubtitleTrack(first.id); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts new file mode 100644 index 000000000..6ce9d5106 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts @@ -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(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts new file mode 100644 index 000000000..6d2fa321c --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts @@ -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, + 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; + /** Whether the timeline accepts pointer interaction at all. */ + interactive: Signal; + /** Drawn segments, for the title in the label; none means time only. */ + segments?: Signal; +} + +/** + * 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(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); + } + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts new file mode 100644 index 000000000..54baa007d --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts @@ -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(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts new file mode 100644 index 000000000..b3003d000 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts @@ -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, + 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; +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline.ts index b6ac54130..985a62af5 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-timeline.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline.ts @@ -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(null); - constructor(private readonly state: Signal) {} + constructor( + private readonly state: Signal, + 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(() => + 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)); } diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts new file mode 100644 index 000000000..69ba70311 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts @@ -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; + let state: WritableSignal; + let capabilities: WritableSignal; + let showControls: WritableSignal; + let settingsOpen: WritableSignal; + let upNext: ControlsUpNext; + + const setState = (overrides: Partial) => + 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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts new file mode 100644 index 000000000..0b13db46f --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts @@ -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; + state: Signal; + capabilities: Signal; + showControls: Signal; + /** The settings panel covers the card's corner; it yields while open. */ + settingsOpen: Signal; +} + +/** + * 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(() => { + 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)) + ); +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts b/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts index 7c3dd81fc..d19b89966 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts @@ -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() ) { diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts index 0b23e2f27..d1318b98c 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts @@ -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( - '[data-test-id="player-controls-quality"]' + '[data-test-id="player-controls-settings-button"]' ); expect(trigger).not.toBeNull(); trigger?.click(); fixture.detectChanges(); - expect(component.menus.qualityOpen()).toBe(true); + expect(component.menus.settingsOpen()).toBe(true); const entries = Array.from( root.querySelectorAll( - '.player-controls__track-popover .player-controls__track' + '[data-test-id="player-settings-quality"] .player-settings__option' ) ); expect(entries.map((entry) => entry.textContent?.trim())).toEqual([ @@ -314,9 +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') diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts new file mode 100644 index 000000000..57e0313fd --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts @@ -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 = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), + setQualityLevel: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + togglePictureInPicture: jest.fn(), + }; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +/** + * The "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; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + fake.state.set({ ...createEmptyControlsState(), ...overrides }); + + const resizeTo = (width: number) => { + for (const callback of resizeCallbacks) { + callback([ + { + borderBoxSize: [{ inlineSize: width, blockSize: 300 }], + contentRect: { width } as DOMRectReadOnly, + } as unknown as ResizeObserverEntry, + ]); + } + fixture.detectChanges(); + }; + + 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 + ).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 + ); + 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(); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index 2de53d6f3..60acd35e0 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -22,6 +22,9 @@
@@ -39,6 +42,9 @@
@@ -75,7 +83,10 @@
@if (streamStats.hasRows()) {
- @for (row of streamStats.rows(); track row.labelKey) { + @for ( + row of streamStats.rows(); + track row.labelKey + ) {
{{ row.labelKey | translate }}
{{ row.value }}
@@ -97,10 +108,27 @@ } @if (showControls()) { + +
-
- + - @if (capabilities().seriesNavigation) { - - - - } - - @if (capabilities().seek) { - - - - } -
- -
- @if (capabilities().seek) { - - } -
- {{ formatTime(timelineValue()) }} - @if (state().canSeek) { - {{ formatTime(timelineDuration()) }} - } @else if (state().isLive) { - +
+ @if (capabilities().volume) { +
- - LIVE - - } @else { - --:-- - } -
- @if (recordingStatusText(); as recordingStatus) { -
- {{ - isRecording() ? 'fiber_manual_record' : 'check_circle' - }} - {{ recordingStatus }} -
- } -
- -
- @if (capabilities().volume) { -
- - @if (menus.volumeOpen()) { - - } -
- } + } @else if (menus.volumeOpen()) { + + } +
+ } +
- @if (hasAudioTracks()) { -
+
+ @if (capabilities().seriesNavigation) { - @if (menus.audioOpen()) { - - } -
- } + } - @if (hasSubtitleTracks()) { -
+ @if (capabilities().seek) { + } + + + + @if (capabilities().seek) { + + } + + @if (capabilities().seriesNavigation) { + + } +
+ +
+ @if ( + settings.available() && layout.roomy() && !settings.isOpen() + ) { + @if (settings.groups().subtitles) { + + } + @if (settings.groups().speed) { + + } + } + + @if (settings.available()) { + + } + + @if (canRecord() && !(settings.isOpen() && layout.roomy())) { + - @if (menus.subtitleOpen()) { - - } -
- } + } - @if (hasQualityLevels()) { -
+ @if ( + capabilities().pictureInPicture && + !(settings.isOpen() && layout.roomy()) + ) { - @if (menus.qualityOpen()) { - - } -
- } - - @if (capabilities().playbackSpeed) { -
- - @if (menus.speedOpen()) { - - } -
- } + } - @if (capabilities().aspectRatio) { -
+ @if (capabilities().fullscreen) { - @if (menus.aspectOpen()) { - - } -
- } - - @if (canRecord()) { - - } - - @if (capabilities().pictureInPicture) { - - } - - @if (capabilities().fullscreen) { - - } + } +
+ + @if (settings.isOpen()) { + + } + + @if (upNextCard.item(); as item) { + + } } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts index b8e7e24cf..20ca82b59 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts @@ -144,7 +144,9 @@ describe('PlayerControlsComponent interactions', () => { expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95); expect(component.feedback.current()?.label).toBe('95%'); - component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: -100 })); + component.volumeInteractions.wheel( + new WheelEvent('wheel', { deltaY: -100 }) + ); expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1); }); @@ -170,7 +172,9 @@ describe('PlayerControlsComponent interactions', () => { }); it('keeps optimistic volume across capability and visibility changes', () => { - component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 })); + component.volumeInteractions.wheel( + new WheelEvent('wheel', { deltaY: 100 }) + ); expect(component.displayVolume()).toBe(0.95); setState({ positionSeconds: 15, volume: 1 }); @@ -187,7 +191,9 @@ describe('PlayerControlsComponent interactions', () => { }); it('reapplies persisted volume when the capability returns', () => { - component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 })); + component.volumeInteractions.wheel( + new WheelEvent('wheel', { deltaY: 100 }) + ); fake.commands.setVolume.mockClear(); setCapabilities({ volume: false }); @@ -202,7 +208,9 @@ describe('PlayerControlsComponent interactions', () => { }); it('reconciles volume when the controller changes at the same value', () => { - component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 })); + component.volumeInteractions.wheel( + new WheelEvent('wheel', { deltaY: 100 }) + ); expect(component.displayVolume()).toBe(0.95); localStorage.removeItem('volume'); @@ -218,40 +226,40 @@ describe('PlayerControlsComponent interactions', () => { }); }); - describe('menu selections', () => { - it('applies a speed preset and closes the menu', () => { + describe('settings panel selections', () => { + it('applies a speed preset from the chip-opened panel and keeps it open', () => { setCapabilities({ playbackSpeed: true }); fixture.detectChanges(); - query('[aria-label="EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED"]')?.click(); + query('[data-test-id="player-controls-speed-chip"]')?.click(); fixture.detectChanges(); - expect(component.menus.speedOpen()).toBe(true); + expect(component.menus.settingsOpen()).toBe(true); + expect(component.menus.settingsFocus()).toBe('speed'); - const preset = queryAll('.player-controls__track').find((item) => - item.textContent?.includes('1.5×') - ); + const preset = queryAll( + '[data-test-id="player-settings-speed"] .player-settings__seg-item' + ).find((item) => item.textContent?.includes('1.5×')); preset?.click(); fixture.detectChanges(); expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5); - expect(component.menus.speedOpen()).toBe(false); + expect(component.menus.settingsOpen()).toBe(true); }); - it('applies an aspect preset and closes the menu', () => { + it('applies an aspect preset from the panel', () => { setCapabilities({ aspectRatio: true }); fixture.detectChanges(); - query('[aria-label="EMBEDDED_MPV.PLAYER.ASPECT_RATIO"]')?.click(); + component.settings.open('aspect'); fixture.detectChanges(); - const preset = queryAll('.player-controls__track').find((item) => - item.textContent?.includes('16:9') - ); + const preset = queryAll( + '[data-test-id="player-settings-aspect"] .player-settings__seg-item' + ).find((item) => item.textContent?.includes('16:9')); preset?.click(); fixture.detectChanges(); expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9'); - expect(component.menus.aspectOpen()).toBe(false); }); it('disables subtitles via the Off entry', () => { @@ -262,22 +270,24 @@ describe('PlayerControlsComponent interactions', () => { }); fixture.detectChanges(); - query('[aria-label="EMBEDDED_MPV.PLAYER.SUBTITLES"]')?.click(); + query('[data-test-id="player-controls-subtitle-chip"]')?.click(); fixture.detectChanges(); + expect(component.menus.settingsFocus()).toBe('subtitles'); - queryAll('.player-controls__track')[0]?.click(); + queryAll( + '[data-test-id="player-settings-subtitles"] .player-settings__option' + )[0]?.click(); expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(-1); - expect(component.menus.subtitleOpen()).toBe(false); }); it('opening one menu closes the others', () => { - setCapabilities({ playbackSpeed: true, aspectRatio: true }); + setCapabilities({ playbackSpeed: true, volume: true }); fixture.detectChanges(); - component.toggleMenu('speed'); - component.toggleMenu('aspect'); - expect(component.menus.speedOpen()).toBe(false); - expect(component.menus.aspectOpen()).toBe(true); + component.settings.open('speed'); + component.toggleMenu('volume'); + expect(component.menus.settingsOpen()).toBe(false); + expect(component.menus.volumeOpen()).toBe(true); expect(component.anyMenuOpen()).toBe(true); }); }); @@ -339,17 +349,25 @@ describe('PlayerControlsComponent interactions', () => { it('keeps the controls visible while a menu is open, hides after close', () => { setCapabilities({ playbackSpeed: true }); fixture.detectChanges(); - component.toggleMenu('speed'); + component.settings.open('speed'); fixture.detectChanges(); jest.advanceTimersByTime(10000); fixture.detectChanges(); expect(component.controlsAreVisible()).toBe(true); + // A choice keeps the panel (and the controls) on screen… component.menuSelection.speed(1.25); fixture.detectChanges(); jest.advanceTimersByTime(10000); fixture.detectChanges(); + expect(component.controlsAreVisible()).toBe(true); + + // …closing it re-arms the auto-hide. + component.settings.close(); + fixture.detectChanges(); + jest.advanceTimersByTime(10000); + fixture.detectChanges(); expect(component.controlsAreVisible()).toBe(false); }); @@ -403,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); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index 6b11fa467..880a52c58 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -1,24 +1,40 @@ :host { - --pc-accent: #ffffff; - --pc-track: rgba(255, 255, 255, 0.3); - --pc-thumb-ring: rgba(255, 255, 255, 0.95); - --pc-panel-bg: rgba(18, 18, 20, 0.92); - --pc-panel-border: rgba(255, 255, 255, 0.16); - --pc-text: #ffffff; + // Overlay palette. Deliberately literal and theme-independent: the shared + // controls always sit on video, and the UI guidelines keep them on one + // light-on-dark palette in both app themes. The values are the design + // tokens of the "Hybrid" controls concept (accent blue = primary action + // and progress; cyan = something is on; violet = a value was changed). + --pc-accent-blue: #4f8eff; + // Fills that carry a white glyph (play, active tune) use deeper blues: + // white on #4f8eff is only 3.2:1, too close to the 3:1 floor once a + // 1x display antialiases the glyph. These give 4.4:1 and 5.3:1. + --pc-accent-blue-strong: #3474e8; + --pc-accent-blue-hover: #2a66d6; + --pc-accent-cyan: #5cd6ff; + --pc-accent-violet: #b599ff; + --pc-text: #e7ecf3; + --pc-text-secondary: #9aa3b2; + --pc-text-tertiary: #6b7384; --pc-text-dim: rgba(255, 255, 255, 0.82); + --pc-scrim-ink: 4, 7, 11; + --pc-track: rgba(255, 255, 255, 0.18); + --pc-hover: rgba(255, 255, 255, 0.1); + --pc-glass-bg: rgba(12, 16, 23, 0.72); + --pc-glass-border: rgba(255, 255, 255, 0.09); + --pc-font-mono: + ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; position: absolute; inset: 0; z-index: 2; display: block; // The control layer is transparent to the mouse so the video underneath - // stays interactive; only the bar/popovers opt back in to pointer events. + // stays interactive; only the dock/popovers opt back in to pointer events. pointer-events: none; color: var(--pc-text); - // Become a query container so popovers can size against the actual player - // box (the host fills the player via inset:0) instead of fixed pixels or - // the viewport — they grow to fit when there is room and shrink to scroll - // when the player is small, never spilling past its edges. + // Become a query container so the dock and popovers can size against the + // actual player box (the host fills the player via inset:0) instead of + // fixed pixels or the viewport. container-type: size; container-name: player-controls; } @@ -77,34 +93,59 @@ // footage. Deliberately one element rather than a background per consumer: two // overlapping gradients would darken the overlap twice. // -// The stops are the bottom bar's, mirrored, so the two edges of the player read -// as one system. +// The stops mirror the bottom scrim so the two edges of the player read as one +// system; the height follows the player so a tall fullscreen stage gets a +// proportionally deeper fade, never less than the chrome it backs. .player-controls__top-scrim { position: absolute; top: 0; right: 0; left: 0; z-index: 1; - // Mirrors the bar's 48px top padding + 64px min-height. - height: 112px; + height: max(28%, 112px); opacity: 0; // Purely decorative: clicks belong to the video surface underneath. pointer-events: none; background: linear-gradient( to bottom, - rgba(0, 0, 0, 0.78) 0%, - rgba(0, 0, 0, 0.45) 45%, + rgba(var(--pc-scrim-ink), 0.78) 0%, + rgba(var(--pc-scrim-ink), 0.45) 45%, transparent 100% ); // Fades with the controls, but does not slide: a moving scrim edge is // visible against video in a way a moving control is not. - transition: opacity 180ms ease-out; + transition: opacity 200ms ease-out; } .player-controls__top-scrim--visible { opacity: 1; } +// Backdrop of the dock. Same rule as the top scrim: one gradient for the +// timeline and the button row together, and no surface under the dock itself +// — the controls sit directly on the video. +.player-controls__bottom-scrim { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + height: 55%; + opacity: 0; + pointer-events: none; + background: linear-gradient( + to top, + rgba(var(--pc-scrim-ink), 0.92) 0%, + rgba(var(--pc-scrim-ink), 0.55) 45%, + rgba(var(--pc-scrim-ink), 0) 100% + ); + transition: opacity 200ms ease-out; +} + +.player-controls__bottom-scrim--visible { + opacity: 1; +} + .player-controls__title { position: absolute; top: 0; @@ -115,20 +156,20 @@ flex-direction: column; gap: 4px; // Bottom padding keeps the text inside the top scrim's opaque half. - padding: 20px 24px 40px; + padding: 20px 28px 40px; opacity: 0; // Purely informative: never intercept clicks meant for the video surface. pointer-events: none; transform: translateY(-8px); transition: - opacity 180ms ease-out, - transform 200ms ease-out; + opacity 200ms ease-out, + transform 220ms ease-out; } // Only when the corner button is actually rendered: keep the fullscreen title // from running underneath it (a long title ellipsizes at the full width). .player-controls__title:has(~ .player-controls__corner) { - padding-right: 64px; + padding-right: 72px; } .player-controls__title--visible { @@ -139,8 +180,8 @@ .player-controls__title-primary { overflow: hidden; color: var(--pc-text); - font-size: 1.15rem; - font-weight: 600; + font-size: 17px; + font-weight: 650; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; @@ -149,29 +190,29 @@ .player-controls__title-secondary { overflow: hidden; - color: var(--pc-text-dim); - font-size: 0.85rem; + color: var(--pc-text-secondary); + font-size: 12.5px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); } -// Top-right corner chrome (stream-info button). It fades with the bottom bar +// Top-right corner chrome (stream-info button). It fades with the dock // but is its own layer: the title scrim above it is click-through, so the // button opts back into pointer events on its own. .player-controls__corner { position: absolute; - top: 8px; - right: 8px; + top: 16px; + right: 20px; z-index: 3; display: flex; opacity: 0; pointer-events: none; transform: translateY(-8px); transition: - opacity 180ms ease-out, - transform 200ms ease-out; + opacity 200ms ease-out, + transform 220ms ease-out; } .player-controls__corner--visible { @@ -180,34 +221,23 @@ transform: translateY(0); } +// The dock: timeline row above, control row below. No background of its own; +// the bottom scrim is the backdrop. .player-controls__bar { position: absolute; - right: 0; - bottom: 0; - left: 0; + right: 32px; + bottom: 22px; + left: 32px; z-index: 2; - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 16px; - align-items: center; - align-content: end; - min-height: 64px; - // Generous top padding lets the scrim rise above the controls and fade out. - padding: 48px 16px 12px; + display: flex; + flex-direction: column; + gap: 12px; opacity: 0; pointer-events: none; transform: translateY(8px); - // True-overlay scrim: opaque-ish black at the bottom fading to fully - // transparent at the top, so controls read clearly over full-bleed video. - background: linear-gradient( - to top, - rgba(0, 0, 0, 0.78) 0%, - rgba(0, 0, 0, 0.45) 45%, - transparent 100% - ); transition: - opacity 180ms ease-out, - transform 200ms ease-out; + opacity 200ms ease-out, + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } .player-controls__bar--visible { @@ -216,94 +246,64 @@ transform: translateY(0); } +// The wide settings panel sits beside the video: the dock and the top chrome +// slide left so nothing is covered, in sync with the panel's own slide-in. +.player-controls__bar--panel-open { + right: 370px; +} + +.player-controls__title--panel-open { + right: 354px; +} + +.player-controls__corner--panel-open { + right: 374px; +} + +// A compact sheet replaces the dock while open; the sheet carries the +// controls' pointer events, and the video tap that closes it lands on the +// surface underneath. +.player-controls__bar--sheet-open { + opacity: 0; + pointer-events: none; +} + +// --- Control row ------------------------------------------------------------ + +// Each side column keeps at least its own content width: when the action +// cluster needs more than half of what the transport leaves, the transport +// slides off-centre instead of the actions overlapping it or leaving the +// player. Which extras exist at all is decided by `ControlsLayout`. +.player-controls__row { + display: grid; + grid-template-columns: minmax(min-content, 1fr) auto minmax( + min-content, + 1fr + ); + gap: 8px; + align-items: center; +} + +.player-controls__left { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + .player-controls__transport { display: flex; align-items: center; + justify-content: center; gap: 8px; } -.player-controls__timeline { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} - -.player-controls__time { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - color: var(--pc-text-dim); - font-size: 0.78rem; - font-variant-numeric: tabular-nums; -} - -.player-controls__recording-status { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - color: rgba(255, 255, 255, 0.78); - 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; - } -} - .player-controls__actions { display: flex; align-items: center; - gap: 8px; + justify-content: flex-end; + gap: 4px; + min-width: 0; } .player-controls__popover-anchor { @@ -311,47 +311,208 @@ display: flex; } +.player-controls__volume { + align-items: center; + gap: 4px; +} + .player-controls__record-button--active { color: var(--mat-sys-error, #ff5252) !important; } -// Scope to :host — the controls render directly under the component host -// (`.player-controls-host`); there is no `.player-controls` wrapper element, so -// selectors prefixed with `.player-controls ` never matched (which is why the -// icons were only white on :hover, the one rule scoped to `.player-controls__bar`). -:host :is(button[mat-icon-button]) { - // Material drives the icon colour through MDC tokens, not plain `color`, - // so force both — otherwise the icons render in the dim theme colour. +// Value chips (subtitles, speed): the two settings a viewer changes mid-film. +// Neutral until something is on (cyan) or changed (violet). +.player-controls__chip { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + height: 32px; + max-width: 132px; + padding: 0 11px; + color: var(--pc-text); + background: transparent; + border: 0; + border-radius: 10px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 600; + transition: background 120ms ease-out; +} + +.player-controls__chip mat-icon { + flex: 0 0 auto; + width: 19px; + height: 19px; + font-size: 19px; +} + +.player-controls__chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-controls__chip:hover, +.player-controls__chip:focus-visible { + background: var(--pc-hover); +} + +.player-controls__chip:focus-visible { + outline: 2px solid var(--pc-text); + outline-offset: 2px; +} + +.player-controls__chip--on { + color: var(--pc-accent-cyan); + background: rgba(92, 214, 255, 0.15); +} + +.player-controls__chip--modified { + color: var(--pc-accent-violet); + background: rgba(181, 153, 255, 0.16); +} + +.player-controls__chip--on:hover, +.player-controls__chip--on:focus-visible { + background: rgba(92, 214, 255, 0.24); +} + +.player-controls__chip--modified:hover, +.player-controls__chip--modified:focus-visible { + background: rgba(181, 153, 255, 0.26); +} + +// The settings button: filled in the accent while its panel is open, and +// carrying state dots where the chips are folded away (compact). +:host :is(.player-controls__tune) { + position: relative; +} + +:host :is(.player-controls__tune--active), +:host :is(.player-controls__tune--active:hover) { color: #ffffff; - --mdc-icon-button-icon-color: #ffffff; + background: var(--pc-accent-blue-strong); --mat-icon-button-icon-color: #ffffff; } -:host :is(button[mat-icon-button]) mat-icon { +.player-controls__tune-dots { + position: absolute; + bottom: 3px; + left: 50%; + display: flex; + gap: 4px; + transform: translateX(-50%); + pointer-events: none; +} + +.player-controls__tune-dot { + width: 5px; + height: 5px; + border-radius: 50%; +} + +.player-controls__tune-dot--cyan { + background: var(--pc-accent-cyan); +} + +.player-controls__tune-dot--violet { + background: var(--pc-accent-violet); +} + +// Primary action: a filled circle in the accent colour. Not a Material icon +// button on purpose — the ring and the fill are the design, and Material's +// state layer would tint them. +.player-controls__play { + display: grid; + flex: 0 0 auto; + place-items: center; + width: 52px; + height: 52px; + margin: 0 6px; + padding: 0; + border: 0; + border-radius: 50%; color: #ffffff; + background: var(--pc-accent-blue-strong); + box-shadow: + 0 0 0 6px rgba(79, 142, 255, 0.18), + 0 6px 20px rgba(79, 142, 255, 0.35); + cursor: pointer; + transition: background 120ms ease-out; +} + +.player-controls__play mat-icon { + width: 30px; + height: 30px; + font-size: 30px; + color: #ffffff; +} + +// Hover darkens rather than lightens, and does not scale: the white glyph +// keeps its contrast against the fill, and a scaled glyph is resampled and +// blurred on 1x displays (the theme E2E rasterizes exactly this state). +.player-controls__play:hover:not(:disabled) { + background: var(--pc-accent-blue-hover); +} + +.player-controls__play:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 3px; +} + +.player-controls__play:disabled { + opacity: 0.55; + cursor: default; +} + +// Scope to :host — the controls render directly under the component host +// (`.player-controls-host`); there is no `.player-controls` wrapper element. +:host :is(button[mat-icon-button]) { + width: 40px; + height: 40px; + padding: 8px; + // Material drives the icon colour and geometry through its tokens, not + // plain `color`/`width`, so set both — otherwise the icons render in the + // dim theme colour inside a 48px circle. + color: var(--pc-text); + --mat-icon-button-icon-color: var(--pc-text); + --mat-icon-button-state-layer-size: 40px; + --mat-icon-button-container-shape: 12px; + --mat-icon-button-hover-state-layer-opacity: 0; + border-radius: 12px; + transition: background 120ms ease-out; +} + +:host :is(button[mat-icon-button]) mat-icon { + color: var(--pc-text); } :host :is(button[mat-icon-button][disabled]) { // Keep disabled affordances visibly muted. - --mdc-icon-button-icon-color: rgba(255, 255, 255, 0.38); - color: rgba(255, 255, 255, 0.38); + --mat-icon-button-icon-color: var(--pc-text-tertiary); + color: var(--pc-text-tertiary); } :host :is(button[mat-icon-button][disabled]) mat-icon { - color: rgba(255, 255, 255, 0.38); + color: var(--pc-text-tertiary); } :host :is(.player-controls__bar, .player-controls__corner) - :is(button[mat-icon-button]:hover) { + :is(button[mat-icon-button]:hover:not([disabled])) { color: #ffffff; - background: rgba(255, 255, 255, 0.16); + background: var(--pc-hover); } +// --- Sliders ---------------------------------------------------------------- + .player-controls__slider { --slider-progress: 0%; width: 100%; height: 18px; + margin: 0; appearance: none; -webkit-appearance: none; background: transparent; @@ -359,7 +520,7 @@ } .player-controls__slider:focus-visible { - outline: 2px solid var(--pc-accent); + outline: 2px solid var(--pc-text); outline-offset: 4px; border-radius: 4px; } @@ -369,7 +530,7 @@ border-radius: 999px; background: linear-gradient( to right, - var(--pc-accent) var(--slider-progress), + var(--pc-text) var(--slider-progress), var(--pc-track) var(--slider-progress) ); } @@ -377,13 +538,13 @@ .player-controls__slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - width: 14px; - height: 14px; - margin-top: -5px; - border: 2px solid var(--pc-thumb-ring); + width: 12px; + height: 12px; + margin-top: -4px; + border: 0; border-radius: 50%; - background: var(--pc-accent); - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); + background: #ffffff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55); transition: transform 120ms ease-out; } @@ -398,7 +559,6 @@ .player-controls__slider:disabled::-webkit-slider-thumb { background: rgba(255, 255, 255, 0.45); - border-color: rgba(255, 255, 255, 0.6); box-shadow: none; } @@ -411,16 +571,16 @@ .player-controls__slider::-moz-range-progress { height: 4px; border-radius: 999px; - background: var(--pc-accent); + background: var(--pc-text); } .player-controls__slider::-moz-range-thumb { - width: 14px; - height: 14px; - border: 2px solid var(--pc-thumb-ring); + width: 12px; + height: 12px; + border: 0; border-radius: 50%; - background: var(--pc-accent); - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); + background: #ffffff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55); transition: transform 120ms ease-out; } @@ -431,24 +591,30 @@ .player-controls__slider:disabled::-moz-range-thumb { background: rgba(255, 255, 255, 0.45); - border-color: rgba(255, 255, 255, 0.6); box-shadow: none; } +.player-controls__slider--inline { + width: 72px; + margin-left: 4px; +} + +// --- Popovers --------------------------------------------------------------- + .player-controls__panel { min-width: 0; color: var(--pc-text); - background: var(--pc-panel-bg); - border: 1px solid var(--pc-panel-border); + background: var(--pc-glass-bg); + border: 1px solid var(--pc-glass-border); border-radius: 14px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); - backdrop-filter: blur(18px); + backdrop-filter: blur(18px) saturate(1.3); } .player-controls__volume-popover { position: absolute; bottom: calc(100% + 6px); - right: 0; + left: 0; display: grid; grid-template-columns: 1fr; gap: 4px; @@ -481,29 +647,10 @@ white-space: nowrap; } -.player-controls__track-popover { - position: absolute; - bottom: calc(100% + 6px); - right: 0; - display: flex; - flex-direction: column; - min-width: 220px; - // Cap to the player box (minus a small gutter) so the panel never spills - // past the player's edges on small windows; falls back to the viewport - // where container units are unavailable. - max-width: min(420px, calc(100cqw - 24px), 80vw); - // Height grows to fit the list; only when it would exceed the room above - // the controls bar does the inner list start scrolling. 96px ≈ the controls - // bar height plus the 6px gap and a little breathing room. - max-height: min(calc(100cqh - 96px), 70vh); - padding: 10px 12px; - z-index: 3; -} - .player-controls__menu-title { flex: 0 0 auto; padding: 0 2px 6px; - color: rgba(255, 255, 255, 0.7); + color: var(--pc-text-tertiary); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em; @@ -521,7 +668,7 @@ flex-direction: column; min-width: 240px; max-width: min(360px, calc(100cqw - 24px), 80vw); - max-height: min(calc(100cqh - 96px), 70vh); + max-height: min(calc(100cqh - 120px), 70vh); padding: 10px 12px; overflow-y: auto; scrollbar-width: thin; @@ -563,257 +710,98 @@ font-size: 0.8rem; } -.player-controls__track-list { - display: flex; - flex-direction: column; - gap: 4px; - // No fixed cap: the panel's max-height bounds the popover, and the title - // above stays pinned while this list takes the remaining space and scrolls - // only when the content genuinely exceeds it. - flex: 0 1 auto; - min-height: 0; - overflow-y: auto; - scrollbar-width: thin; -} - -.player-controls__track { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 10px; - color: var(--pc-text); - background: transparent; - border: 0; - border-radius: 10px; - cursor: pointer; - font: inherit; - text-align: left; -} - -.player-controls__track span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.player-controls__track:hover, -.player-controls__track:focus-visible, -.player-controls__track--selected { - background: rgba(255, 255, 255, 0.16); -} - -.player-controls__track mat-icon { - color: #ffffff; - font-size: 18px; - width: 18px; - height: 18px; -} - -.player-controls__load-subtitle mat-icon { - color: rgba(255, 255, 255, 0.85); -} - -// Single scroll owner for the subtitle popover's list + delay/style sections: -// on short players (e.g. the 220px portal player) the panel's max-height cap -// must scroll ALL content — sections outside the list's own scroll area would -// otherwise clip or overflow into the controls bar. -.player-controls__subtitle-body { - display: flex; - flex-direction: column; - flex: 0 1 auto; - min-height: 0; - overflow-y: auto; - scrollbar-width: thin; -} - -// Inside the shared scroll owner the list must not nest a second scroll area. -.player-controls__subtitle-body .player-controls__track-list { - flex: none; - overflow-y: visible; -} - -.player-controls__subtitle-section { - flex: 0 0 auto; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.12); -} - -.player-controls__subtitle-section-title { - padding: 0 2px 6px; - color: rgba(255, 255, 255, 0.7); - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - white-space: nowrap; -} - -.player-controls__subtitle-delay-row { - display: flex; - align-items: center; - gap: 4px; -} - -.player-controls__subtitle-delay-value { - min-width: 58px; - text-align: center; - font-variant-numeric: tabular-nums; - color: var(--pc-text); -} - -.player-controls__subtitle-preset-row { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - padding-bottom: 4px; -} - -.player-controls__subtitle-chip { - padding: 4px 10px; - color: var(--pc-text); - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.25); - border-radius: 999px; - cursor: pointer; - font: inherit; - font-size: 0.8rem; -} - -.player-controls__subtitle-chip:hover, -.player-controls__subtitle-chip:focus-visible, -.player-controls__subtitle-chip--selected { - background: rgba(255, 255, 255, 0.16); - border-color: rgba(255, 255, 255, 0.6); -} - -.player-controls__subtitle-swatch { - width: 26px; - height: 26px; - border: 2px solid rgba(255, 255, 255, 0.35); - border-radius: 50%; - cursor: pointer; - background: var(--swatch-color, transparent); -} - -// The "engine default" swatch has no color of its own: render a diagonal -// slash so it reads as "no override". -.player-controls__subtitle-swatch--default { - background: - linear-gradient( - 135deg, - transparent 44%, - rgba(255, 255, 255, 0.7) 46%, - rgba(255, 255, 255, 0.7) 54%, - transparent 56% - ), - rgba(255, 255, 255, 0.08); -} - -.player-controls__subtitle-swatch:hover, -.player-controls__subtitle-swatch:focus-visible, -.player-controls__subtitle-swatch--selected { - border-color: #ffffff; -} - -@media (max-width: 720px) { - // Moderately tight viewports keep the single-row layout: the transport and - // action buttons hold their natural width while the middle timeline column - // (the seek slider) absorbs the shrink. Genuinely narrow players reflow to - // two rows via the container query below, which wins over this gap. - .player-controls__bar { - gap: 8px; - } - - .player-controls__actions { - min-width: 0; - } - - .player-controls__volume-popover, - .player-controls__track-popover { - right: 0; - left: auto; - } -} - -// Touch scrubbing needs a taller hit strip and thumb than the 18px mouse -// slider; the 4px visual track is unchanged — only the input's touch area and -// the thumb grow. +// Touch needs a taller hit strip and thumb than the 18px mouse slider; the +// drawn track is unchanged. The timeline row sizes its own strip. @media (pointer: coarse) { .player-controls__slider { height: 28px; } .player-controls__slider::-webkit-slider-thumb { - width: 18px; - height: 18px; - margin-top: -7px; + width: 16px; + height: 16px; + margin-top: -6px; } .player-controls__slider::-moz-range-thumb { - width: 18px; - height: 18px; + width: 16px; + height: 16px; } } -// Narrow players — phone-sized PWA viewports and small windowed/inline -// players — cannot hold transport + timeline + the variable-count actions -// cluster in one row (every action button from volume to fullscreen is -// conditional, so the cluster's width is content-dependent). Give the -// timeline its own full-width row and split the second row between the two -// button clusters. Container-based on purpose: a small inline player inside a -// wide desktop window needs the same treatment as a phone viewport. -@container player-controls (max-width: 640px) { - // Matches the bar's reduced padding below: on a small inline player two - // full-height scrims would tint most of the picture. +// Compact dock — phone-sized PWA viewports and small windowed/inline players. +// Keep this in sync with `COMPACT_LAYOUT_MAX_WIDTH` in controls-layout.ts, +// which decides what is rendered; this block only sizes it. Container-based +// on purpose: a small inline player inside a wide desktop window needs the +// same treatment as a phone viewport. +@container player-controls (max-width: 719px) { .player-controls__top-scrim { - height: 88px; + height: max(28%, 88px); + } + + .player-controls__title { + padding: 12px 16px 28px; + } + + .player-controls__corner { + top: 8px; + right: 8px; } .player-controls__bar { - grid-template-columns: auto minmax(0, 1fr); - grid-template-areas: - 'timeline timeline' - 'transport actions'; - gap: 0 8px; - padding: 36px 10px 8px; + right: 14px; + bottom: 10px; + left: 14px; + gap: 6px; + } + + .player-controls__row { + gap: 4px; } .player-controls__transport { - grid-area: transport; + gap: 4px; } - .player-controls__timeline { - grid-area: timeline; + .player-controls__play { + width: 36px; + height: 36px; + margin: 0 4px; + box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.18); + } + + .player-controls__play mat-icon { + width: 22px; + height: 22px; + font-size: 22px; } .player-controls__actions { - grid-area: actions; position: relative; - justify-self: end; - // When even a dedicated row cannot hold the cluster (worst case: - // series transport plus audio/subtitles/quality/speed/aspect/PiP), - // wrap the actions instead of making them a scroll container. A scroll - // container would clip every absolutely positioned capability panel. + // When even the row cannot hold the cluster (worst case: audio/ + // subtitles/quality/speed/aspect/PiP), wrap the actions instead of + // making them a scroll container. A scroll container would clip + // every absolutely positioned capability panel. flex-wrap: wrap; justify-content: flex-end; max-width: 100%; overflow: visible; } - // Anchor narrow-player panels to the whole actions cluster. This keeps the - // right-aligned panels inside the player and above every wrapped action row. - .player-controls__actions .player-controls__popover-anchor { - position: static; - } - // Compact icon buttons: wins back roughly one button of width per four. .player-controls__bar :is(button[mat-icon-button]) { - width: 40px; - height: 40px; - padding: 8px; - --mdc-icon-button-state-layer-size: 40px; + width: 32px; + height: 32px; + padding: 6px; + border-radius: 9px; + --mat-icon-button-state-layer-size: 32px; + --mat-icon-button-container-shape: 9px; + --mat-icon-button-icon-size: 20px; + } + + .player-controls__bar :is(button[mat-icon-button]) mat-icon { + width: 20px; + height: 20px; + font-size: 20px; } } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts new file mode 100644 index 000000000..aafb3ff6b --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts @@ -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 = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), + setQualityLevel: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + togglePictureInPicture: jest.fn(), + }; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +/** + * The settings panel behind the `tune` button: chips on wide players, the + * panel beside the video, the compact bottom sheet, and the dock changes + * that come with each. + */ +describe('PlayerControlsComponent settings panel', () => { + const originalResizeObserver = globalThis.ResizeObserver; + let resizeCallbacks: ResizeCallback[]; + let fixture: ComponentFixture; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + const queryAll = (selector: string) => + Array.from( + fixture.nativeElement.querySelectorAll( + selector + ) as NodeListOf + ); + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + fake.state.set({ ...createEmptyControlsState(), ...overrides }); + + const resizeTo = (width: number) => { + for (const callback of resizeCallbacks) { + callback([ + { + borderBoxSize: [{ inlineSize: width, blockSize: 300 }], + contentRect: { width } as DOMRectReadOnly, + } as unknown as ResizeObserverEntry, + ]); + } + fixture.detectChanges(); + }; + + const withEverything = () => { + setCapabilities({ + volume: true, + audioTracks: true, + subtitles: true, + externalSubtitles: true, + playbackSpeed: true, + aspectRatio: true, + pictureInPicture: true, + fullscreen: true, + }); + setState({ + audioTracks: [ + { id: 1, label: 'English', selected: true }, + { id: 2, label: 'German', selected: false }, + ], + subtitleTracks: [{ id: 5, label: 'Russian', selected: false }], + canPictureInPicture: true, + }); + fixture.detectChanges(); + }; + + beforeEach(async () => { + resizeCallbacks = []; + class FakeResizeObserver { + constructor(callback: ResizeCallback) { + resizeCallbacks.push(callback); + } + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } + } + globalThis.ResizeObserver = + FakeResizeObserver as unknown as typeof ResizeObserver; + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { + SETTINGS: 'Settings', + CLOSE_SETTINGS: 'Close settings', + SUBTITLES: 'Subtitles', + SUBTITLES_OFF: 'Off', + PLAYBACK_SPEED: 'Playback speed', + AUDIO_TRACKS: 'Audio tracks', + ASPECT_RATIO: 'Aspect ratio', + ASPECT_DEFAULT: 'Default', + ENTER_PICTURE_IN_PICTURE: 'Enter picture-in-picture', + ENTER_FULLSCREEN: 'Enter fullscreen', + }, + }, + }); + translate.use('en'); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('controller', fake.controller); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + globalThis.ResizeObserver = originalResizeObserver; + }); + + describe('wide dock', () => { + beforeEach(withEverything); + + it('shows subtitle and speed chips with their values and state colors', () => { + const subtitles = query( + '[data-test-id="player-controls-subtitle-chip"]' + ); + const speed = query('[data-test-id="player-controls-speed-chip"]'); + expect(subtitles?.textContent).toContain('Off'); + expect(subtitles?.classList).not.toContain( + 'player-controls__chip--on' + ); + expect(speed?.textContent).toContain('1×'); + expect(speed?.classList).not.toContain( + 'player-controls__chip--modified' + ); + + setState({ + audioTracks: fake.state().audioTracks, + subtitleTracks: [{ id: 5, label: 'Russian', selected: true }], + subtitlesEnabled: true, + playbackSpeed: 1.25, + }); + fixture.detectChanges(); + + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ?.textContent + ).toContain('Russian'); + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ?.classList + ).toContain('player-controls__chip--on'); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ?.textContent + ).toContain('1.25×'); + expect( + query('[data-test-id="player-controls-speed-chip"]')?.classList + ).toContain('player-controls__chip--modified'); + }); + + it('opens the panel on the chip group, shifts the dock and folds the chips away', () => { + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + + query('[data-test-id="player-controls-speed-chip"]')?.click(); + fixture.detectChanges(); + + const panel = query( + '[data-test-id="player-controls-settings-panel"]' + ); + expect(panel).not.toBeNull(); + expect(panel?.classList).not.toContain( + 'player-controls__settings--sheet' + ); + expect(panel?.getAttribute('role')).toBe('dialog'); + expect(panel?.getAttribute('aria-label')).toBe('Settings'); + expect( + query('[data-test-id="player-settings-speed"]')?.classList + ).toContain('player-settings__group--focused'); + expect(query('.player-controls__bar')?.classList).toContain( + 'player-controls__bar--panel-open' + ); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ).toBeNull(); + expect(query('[aria-label="Enter picture-in-picture"]')).toBeNull(); + expect(query('[aria-label="Enter fullscreen"]')).not.toBeNull(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ?.classList + ).toContain('player-controls__tune--active'); + }); + + it('renders every available group in order and applies choices in place', () => { + component.settings.open(); + fixture.detectChanges(); + + expect( + queryAll('.player-settings__group').map((group) => + group.getAttribute('data-group') + ) + ).toEqual(['audio', 'subtitles', 'speed', 'aspect']); + + queryAll( + '[data-test-id="player-settings-audio"] .player-settings__option' + )[1]?.click(); + expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2); + + const preset = queryAll( + '[data-test-id="player-settings-aspect"] .player-settings__seg-item' + ).find((item) => item.textContent?.includes('16:9')); + preset?.click(); + expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9'); + expect(component.settings.isOpen()).toBe(true); + }); + + it('closes from the panel button and from the tune button', () => { + component.settings.open(); + fixture.detectChanges(); + + query('[data-test-id="player-settings-close"]')?.click(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + expect(query('.player-controls__bar')?.classList).not.toContain( + 'player-controls__bar--panel-open' + ); + + query('[data-test-id="player-controls-settings-button"]')?.click(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).not.toBeNull(); + query('[data-test-id="player-controls-settings-button"]')?.click(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + }); + + it('toggles subtitles from the chip context menu without opening the panel', () => { + const chip = query( + '[data-test-id="player-controls-subtitle-chip"]' + ) as HTMLElement; + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + chip.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(5); + expect(component.settings.isOpen()).toBe(false); + }); + + it('closes the panel once no group is left', () => { + component.settings.open('audio'); + fixture.detectChanges(); + + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES }); + fixture.detectChanges(); + + expect(component.settings.isOpen()).toBe(false); + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + }); + }); + + describe('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(); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts index 5d31d59ee..1bd3e1856 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts @@ -77,6 +77,7 @@ describe('PlayerControlsComponent', () => { FORWARD_10_SECONDS: 'Forward 10 seconds', LIVE_STREAM: 'Live stream', PLAYBACK_POSITION: 'Playback position', + SETTINGS: 'Settings', MUTE: 'Mute', UNMUTE: 'Unmute', VOLUME: 'Volume', @@ -107,10 +108,15 @@ describe('PlayerControlsComponent', () => { describe('capability gating', () => { it('hides every optional control when no capability is enabled', () => { expect(query('[aria-label="Back 10 seconds"]')).toBeNull(); - expect(query('[aria-label="Audio tracks"]')).toBeNull(); - expect(query('[aria-label="Subtitles"]')).toBeNull(); - expect(query('[aria-label="Playback speed"]')).toBeNull(); - expect(query('[aria-label="Aspect ratio"]')).toBeNull(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ).toBeNull(); expect(query('.player-controls__record-button')).toBeNull(); expect(query('[aria-label="Enter fullscreen"]')).toBeNull(); expect(query('[aria-label="Mute"]')).toBeNull(); @@ -137,8 +143,16 @@ describe('PlayerControlsComponent', () => { expect(query('.player-controls__record-button')).toBeNull(); }); - it('shows the audio menu only with the capability and more than one track', () => { + it('shows the settings button only with the capability and more than one audio track', () => { setCapabilities({ audioTracks: true }); + setState({ + audioTracks: [{ id: 1, label: 'English', selected: true }], + }); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ).toBeNull(); + setState({ audioTracks: [ { id: 1, label: 'English', selected: true }, @@ -146,14 +160,23 @@ describe('PlayerControlsComponent', () => { ], }); fixture.detectChanges(); - expect(query('[aria-label="Audio tracks"]')).not.toBeNull(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ).not.toBeNull(); + expect(query('[aria-label="Settings"]')).not.toBeNull(); }); - it('shows speed and aspect controls per their capabilities', () => { + it('shows the speed chip and the settings button per their capabilities', () => { setCapabilities({ playbackSpeed: true, aspectRatio: true }); fixture.detectChanges(); - expect(query('[aria-label="Playback speed"]')).not.toBeNull(); - expect(query('[aria-label="Aspect ratio"]')).not.toBeNull(); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ).not.toBeNull(); + // Aspect ratio has no chip of its own: it lives in the panel. + expect(query('[aria-label="Aspect ratio"]')).toBeNull(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ).not.toBeNull(); }); it('shows the fullscreen button when supported', () => { @@ -201,10 +224,10 @@ describe('PlayerControlsComponent', () => { }); fixture.detectChanges(); - query('[aria-label="Audio tracks"]')?.click(); + query('[data-test-id="player-controls-settings-button"]')?.click(); fixture.detectChanges(); const items = fixture.nativeElement.querySelectorAll( - '.player-controls__track' + '[data-test-id="player-settings-audio"] .player-settings__option' ) as NodeListOf; items[1].click(); expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts index 7b0aab109..09585bc5e 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts @@ -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( - '.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); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts index 1c9553d9f..048b70920 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts @@ -205,7 +205,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => { it('a click while a menu is open dismisses it instead of pausing', () => { jest.useFakeTimers(); - component.toggleMenu('speed'); + component.toggleMenu('settings'); expect(component.anyMenuOpen()).toBe(true); surface.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -216,7 +216,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => { }); it('an outside pointerdown closes open menus', () => { - component.toggleMenu('audio'); + component.toggleMenu('settings'); expect(component.anyMenuOpen()).toBe(true); document.body.dispatchEvent( @@ -230,7 +230,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => { const popoverChild = document.createElement('button'); controlsRoot.appendChild(popoverChild); document.body.appendChild(controlsRoot); - component.toggleMenu('audio'); + component.toggleMenu('settings'); popoverChild.dispatchEvent( new MouseEvent('pointerdown', { bubbles: true }) @@ -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'); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts index f40a5a654..a11cb97c1 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts @@ -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; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index daea28aef..477898bec 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -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(null); + /** Chapters / programmes drawn as track segments; null draws one. */ + readonly timelineSegments = input( + null + ); + /** The next episode, for the "Up next" card near the end of this one. */ + readonly upNext = input(null); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); 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; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts index 218824d2f..e4997b56a 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts @@ -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 { value: T; label: string; diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html new file mode 100644 index 000000000..f58ac738b --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html @@ -0,0 +1,439 @@ +@if (mode() === 'sheet') { + +} +
+
{{ title() }}
+ +
+ +
+ @if (groups().audio) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }} +
+
+ @for (track of state().audioTracks; track track.id) { + + } +
+
+ } + + @if (groups().subtitles) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }} +
+
+ @if ( + capabilities().subtitles && + state().subtitleTracks.length > 0 + ) { + + } + @for (track of state().subtitleTracks; track track.id) { + + } + @if (capabilities().externalSubtitles) { + + } +
+ + @if (capabilities().subtitleDelay) { +
+
+ {{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }} +
+
+ + {{ + subtitleDelayLabel(state().subtitleDelaySeconds) + }} + + +
+
+ } + + @if (capabilities().subtitleStyle) { +
+
+ {{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate }} +
+
+ @for ( + preset of subtitleSizePresets; + track preset.value + ) { + + } +
+
+ {{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }} +
+
+ @for ( + preset of subtitleColorPresets; + track preset.label + ) { + + } +
+
+ } +
+ } + + @if (groups().quality) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }} +
+
+ + @for (level of state().qualityLevels; track level.id) { + + } +
+
+ } + + @if (groups().speed) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }} +
+
+ @for (preset of state().speedPresets; track preset.value) { + + } +
+
+ } + + @if (groups().aspect) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }} +
+
+ @for (preset of state().aspectPresets; track preset.value) { + + } +
+
+ } +
diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss new file mode 100644 index 000000000..071d856d8 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss @@ -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; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts new file mode 100644 index 000000000..deb45bdda --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts @@ -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).nativeElement; + readonly controller = input.required(); + readonly settings = input.required(); + readonly selection = input.required(); + readonly subtitleSettings = input.required(); + readonly mode = input('panel'); + readonly title = input(''); + readonly closeRequested = output(); + + readonly state = computed(() => this.controller().state()); + readonly capabilities = computed(() => this.controller().capabilities()); + readonly groups = computed(() => this.settings().groups()); + readonly focusGroup = computed(() => this.settings().focusGroup()); + readonly defaultAspect = computed(() => this.settings().defaultAspect()); + + readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS; + readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS; + readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS; + readonly subtitleDelayLabel = subtitleDelayLabel; + + /** 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('.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; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.html b/libs/ui/playback/src/lib/player-controls/player-timeline.component.html new file mode 100644 index 000000000..b28e252c4 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.html @@ -0,0 +1,98 @@ +{{ + formatTime(timeline().value()) +}} +@if (seekable()) { +
+ + @if (canSeek()) { + + } + @if (hover().percent() !== null) { + + + } + +
+} @else { +
+} +@if (canSeek() && remaining(); as remaining) { + {{ remaining }} +} @else if (isLive()) { + + + LIVE + +} @else { + --:-- +} +@if (recordingStatus(); as status) { +
+ {{ + recording() ? 'fiber_manual_record' : 'check_circle' + }} + {{ status }} +
+} diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss new file mode 100644 index 000000000..bd6275d44 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss @@ -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); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts b/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts new file mode 100644 index 000000000..383014364 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts @@ -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(); + readonly hover = input.required(); + /** 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(null); + readonly recordingStatus = input(null); + readonly recording = input(false); + readonly scrubInput = output(); + readonly scrubCommit = output(); + + readonly formatTime = formatTime; +} diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html new file mode 100644 index 000000000..b70e9151a --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html @@ -0,0 +1,52 @@ + diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss new file mode 100644 index 000000000..1aaede1e7 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss @@ -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; +} diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts new file mode 100644 index 000000000..693a0883f --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts @@ -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; + + 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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts new file mode 100644 index 000000000..0eb751042 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts @@ -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(); + readonly minutesLeft = input.required(); + readonly compact = input(false); + readonly selected = output(); + + /** The still that failed to load; a new URL gets its own attempt. */ + private readonly failedThumbnail = signal(null); + readonly thumbnail = computed(() => { + const url = this.item().thumbnailUrl; + return url && url !== this.failedThumbnail() ? url : null; + }); + + onThumbnailError(): void { + this.failedThumbnail.set(this.item().thumbnailUrl); + } +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts index 1aa24df4b..3fd6fbe35 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts @@ -24,6 +24,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts index 40064d879..a3effea62 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -25,6 +25,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index 4d6e9124c..695d138e4 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -22,6 +22,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(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(); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 387a7ed98..02896ff8a 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -77,6 +77,7 @@ [streamUrl]="streamUrl()" [title]="title()" [mediaTitle]="playerMediaTitle()" + [upNext]="playerUpNext()" [playback]="playback()" [volume]="volume()" [playerOverride]="playerOverride()" diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index f34f6ecb7..1dc63ad30 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -25,6 +25,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 3b810ba97..c8e4bc432 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -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( () => this.upNextEpisodes() ?? [] ); + /** The rail entry after the playing one, across season boundaries. */ + private readonly upNextRailNext = computed(() => { + 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(() => { + 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 @@ -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(); } diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html index 582657c9a..331a38777 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html @@ -14,6 +14,7 @@ [playerSurface]="playerRoot" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index ca208b60e..ab92365dd 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -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(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 58c30608f..5b204c4a9 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -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()" diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 81d391b7d..7cbf5a49d 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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: '
', -}) -class StubVjsPlayerComponent { - readonly options = input(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ - selector: 'app-html-video-player', - template: '
', -}) -class StubHtmlVideoPlayerComponent { - readonly channel = input(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ - selector: 'app-art-player', - template: '
', -}) -class StubArtPlayerComponent { - readonly channel = input(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ - selector: 'app-embedded-mpv-player', - template: '
', -}) -class StubEmbeddedMpvPlayerComponent { - /** Read by the view's channel-panel gate (`EmbeddedMpvEngineReporter`). */ - readonly support = signal(null); - readonly playback = input.required(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly recordingFolder = input(''); - readonly recordingMetadata = input(null); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); - readonly recordingStopped = output(); -} - describe('WebPlayerViewComponent', () => { let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; let fixture: ComponentFixture; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 75be963e1..b567e3f35 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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(null); readonly seriesNavigation = input(null); readonly mediaTitle = input(null); + /** Next episode for the shared controls' "Up next" card; series hosts only. */ + readonly upNext = input(null); readonly alternativeSources = input([]); /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ readonly recordingMetadata = input(null); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index fb57beae0..1b67d68d8 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -20,6 +20,7 @@ export class StubVjsPlayerComponent { readonly options = input(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly interactionEnabled = input(true); @@ -41,6 +42,7 @@ export class StubHtmlVideoPlayerComponent { readonly channel = input(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -63,6 +65,7 @@ export class StubArtPlayerComponent { readonly channel = input(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -95,6 +98,7 @@ export class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly recordingFolder = input(''); readonly recordingMetadata = input(null); readonly seriesNavigation = input(null);