From 7aca654408bf40a95b777bf3216abb924c283bfa Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 8 Oct 2026 18:45:48 +0200 Subject: [PATCH] fix(player): keyboard-reachable, bounded labels for timeline segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catch-up programmes and file chapters both reach the seek bar through timelineSegments, so the fix sits on that shared path: - aria-valuetext reads the translated " · <time>" whenever the scrub or playback value (the keyboard target included) lies in a titled segment, else the plain time. - ControlsTimelineHover becomes ControlsTimelineLabel: besides a hovering pointer, keyboard focus (:focus-visible or a seek key) and a drag preview, which is how touch sees it, anchor the label at the slider value. - The label is two parts: the title ellipsizes at min(320px, 60cqw), the time never truncates. Once laid out it is clamped by its measured width to stay 8px inside the player (clampTimelineLabelLeft). - Boundaries hold 3:1 over any frame: an opaque --pc-timeline-track and white --pc-timeline-separator separators filling the (now 2px) gaps. - LIVE is the translated LIVE_BADGE; the badge and the --:-- placeholder are role="img" named LIVE_STREAM / DURATION_UNKNOWN, in the shared and the native-view Embedded MPV dock. New keys go to all 18 locales; the identical-English baseline gains the punctuation-only template and "LIVE" for de and nl. The epglong Xtream mock scenario adds a 120-character programme title for the new Electron E2E, which tabs to the bar in both themes at 1280 and 800px and in de/ru. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .changes/player-timeline-segment-labels.md | 10 + .../src/xtream-catchup-timeline-label.e2e.ts | 336 ++++++++++++++++++ .../src/xtream-catchup-timeline.e2e.ts | 6 +- apps/web/src/assets/i18n/ar.json | 3 + apps/web/src/assets/i18n/ary.json | 3 + apps/web/src/assets/i18n/by.json | 3 + apps/web/src/assets/i18n/de.json | 3 + apps/web/src/assets/i18n/el.json | 3 + apps/web/src/assets/i18n/en.json | 3 + apps/web/src/assets/i18n/es.json | 3 + apps/web/src/assets/i18n/fr.json | 3 + apps/web/src/assets/i18n/hu.json | 3 + apps/web/src/assets/i18n/it.json | 3 + apps/web/src/assets/i18n/ja.json | 3 + apps/web/src/assets/i18n/ko.json | 3 + apps/web/src/assets/i18n/nl.json | 3 + apps/web/src/assets/i18n/pl.json | 3 + apps/web/src/assets/i18n/pt.json | 3 + apps/web/src/assets/i18n/ru.json | 3 + apps/web/src/assets/i18n/tr.json | 3 + apps/web/src/assets/i18n/zh.json | 3 + apps/web/src/assets/i18n/zhtw.json | 3 + apps/xtream-mock-server/README.md | 1 + apps/xtream-mock-server/src/app/data-store.ts | 23 +- apps/xtream-mock-server/src/app/scenarios.ts | 24 ++ .../xtream-mock-server/src/app/server.spec.ts | 32 ++ apps/xtream-mock-server/src/app/server.ts | 4 +- docs/architecture/player-controls-contract.md | 56 ++- docs/architecture/xtream-mock-server.md | 7 + .../embedded-mpv-player.component.html | 12 +- .../embedded-mpv-player.component.spec.ts | 18 +- .../player-controls/controls-format.utils.ts | 8 + .../controls-timeline-hover.spec.ts | 97 ----- .../controls-timeline-hover.ts | 107 ------ .../controls-timeline-label.spec.ts | 309 ++++++++++++++++ .../controls-timeline-label.ts | 202 +++++++++++ .../controls-timeline-segments.spec.ts | 20 +- .../controls-timeline-segments.ts | 17 +- .../lib/player-controls/controls-timeline.ts | 9 + .../player-controls.component.dock.spec.ts | 141 +++++++- .../player-controls.component.html | 2 +- .../player-controls.component.scss | 7 + ...player-controls.component.timeline.spec.ts | 119 ++++++- .../player-controls.component.ts | 6 +- .../player-controls.palette.spec.ts | 55 +++ .../player-timeline.component.html | 57 ++- .../player-timeline.component.scss | 65 +++- .../player-timeline.component.ts | 62 +++- tools/i18n/identical-en-baseline.json | 20 ++ 49 files changed, 1611 insertions(+), 278 deletions(-) create mode 100644 .changes/player-timeline-segment-labels.md create mode 100644 apps/electron-backend-e2e/src/xtream-catchup-timeline-label.e2e.ts delete mode 100644 libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts delete 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-label.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-timeline-label.ts diff --git a/.changes/player-timeline-segment-labels.md b/.changes/player-timeline-segment-labels.md new file mode 100644 index 000000000..2af78d0d4 --- /dev/null +++ b/.changes/player-timeline-segment-labels.md @@ -0,0 +1,10 @@ +--- +type: fix +area: player +--- + +Programme names on the seek bar now reach keyboard, touch and screen-reader +users: the bar announces the programme with the time, and its label appears +while you seek with the arrow keys or drag on a touch screen. Long names are +shortened instead of spilling past the player, and boundaries between +programmes are easier to see. LIVE is translated. diff --git a/apps/electron-backend-e2e/src/xtream-catchup-timeline-label.e2e.ts b/apps/electron-backend-e2e/src/xtream-catchup-timeline-label.e2e.ts new file mode 100644 index 000000000..872f3368d --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-catchup-timeline-label.e2e.ts @@ -0,0 +1,336 @@ +import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + addXtreamPortal, + channelItemByTitle, + clickCategoryByNameExact, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openWorkspaceSection, + resetMockServers, + saveSettings, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { fetchXtreamEpgFixture } from './portal-mock-fixtures'; +import { applyTheme } from './theme-contrast'; + +/** + * Catch-up programme names on the shared controls' seek bar reach keyboard + * and screen-reader users, and the label stays inside the player. The + * `epglong` mock user is the EPG fixture with a 120-character title on the + * past programme, whose timeshift URL plays a local clip. + */ + +const CHANNEL = 'Timezone News'; +const CREDENTIALS = { username: 'epglong', password: 'epglong' }; +/** `--pc-glass-bg-dense`: the label is theme-independent. */ +const LABEL_GLASS = 'rgba(12, 16, 23, 0.86)'; + +interface Messages { + WORKSPACE: { SHELL: { RAIL_LIVE: string } }; + EMBEDDED_MPV: { + PLAYER: { + LIVE_STREAM: string; + LIVE_BADGE: string; + TIMELINE_SEGMENT_POSITION: string; + }; + }; +} + +function messages(locale: string): Messages { + return JSON.parse( + readFileSync( + join(__dirname, `../../web/src/assets/i18n/${locale}.json`), + 'utf8' + ) + ) as Messages; +} + +function valueText(template: string, title: string, time: string): string { + return template.replace('{{title}}', title).replace('{{time}}', time); +} + +/** Imports the source and opens the guide of the catch-up channel. */ +async function openChannel( + page: Page, + request: Parameters<typeof fetchXtreamEpgFixture>[0], + locale: string | null +) { + const fixture = await fetchXtreamEpgFixture(request, CREDENTIALS); + const longTitle = fixture.fullEpg + .map((programme) => programme.title) + .find((title) => title.length >= 120); + expect(longTitle).toHaveLength(120); + + // Keep the live stub request pending: everything played here is local. + await page.route('https://test-streams.mux.dev/**', () => undefined); + await addXtreamPortal(page, { name: 'Long catch-up titles', ...CREDENTIALS }); + await waitForXtreamWorkspaceReady(page); + if (locale) { + await openSettings(page); + await page.getByTestId('select-language').click(); + await page.locator(`mat-option[data-test-id="${locale}"]`).click(); + await saveSettings(page); + } + await openWorkspaceSection( + page, + messages(locale ?? 'en').WORKSPACE.SHELL.RAIL_LIVE + ); + await clickCategoryByNameExact(page, fixture.categoryName); + const row = channelItemByTitle(page, CHANNEL).first(); + await expect(row).toBeVisible({ timeout: 20000 }); + await row.click(); + const player = page.locator('app-web-player-view'); + await expect(player).toBeVisible({ timeout: 20000 }); + return { player, row, longTitle: longTitle ?? '' }; +} + +/** Plays the long-titled programme's archive from the guide. */ +async function playCatchup(page: Page, player: Locator, longTitle: string) { + const block = page + .locator('app-epg-timeline .epg-timeline__block') + .filter({ hasText: longTitle.slice(0, 40) }) + .first(); + await expect(block).toBeVisible({ timeout: 20000 }); + await block.click(); + await expect( + player.locator('.player-controls__timeline-segment--titled') + ).toHaveCount(1, { timeout: 30000 }); + const controls = player.locator('app-player-controls'); + const slider = controls.locator('.player-controls__slider--timeline'); + await expect(slider).toBeEnabled({ timeout: 30000 }); + // Paused, so the position the assertions read stands still. + const video = player.locator('video').first(); + if (!(await video.evaluate((el: HTMLVideoElement) => el.paused))) { + await controls.locator('[data-test-id="player-controls-play"]').click(); + } + await expect + .poll(() => video.evaluate((el: HTMLVideoElement) => el.paused)) + .toBe(true); + // Park the pointer off the bar: a hovering pointer outranks the keyboard. + await page.mouse.move(1, 1); + return { controls, slider }; +} + +/** + * Reaches the seek bar from the keyboard: it is the next Tab stop after the + * stream-info button in the top corner. + */ +async function tabToSeekBar(controls: Locator, slider: Locator) { + await controls + .locator('[data-test-id="player-controls-stream-info-button"]') + .focus(); + await controls.page().keyboard.press('Tab'); + await expect(slider).toBeFocused(); + expect( + await slider.evaluate((element) => element.matches(':focus-visible')) + ).toBe(true); +} + +/** The label's box against the player's (the controls host fills it). */ +function labelPlacement(controls: Locator) { + return controls.evaluate((host) => { + const part = (name: string) => + host.querySelector<HTMLElement>( + `[data-test-id="player-controls-timeline-label${name}"]` + ); + const label = part(''); + const title = part('-title'); + const time = part('-time'); + if (!label || !time) { + return null; + } + const player = host.getBoundingClientRect(); + const box = label.getBoundingClientRect(); + const timeBox = time.getBoundingClientRect(); + return { + fromLeft: Math.round(box.left - player.left), + fromRight: Math.round(player.right - box.right), + insideVertically: + box.top >= player.top && box.bottom <= player.bottom, + width: Math.round(box.width), + playerWidth: Math.round(player.width), + title: title?.textContent ?? null, + titleEllipsized: title + ? title.scrollWidth > title.clientWidth && + getComputedStyle(title).textOverflow === 'ellipsis' + : false, + time: time.textContent, + timeWhole: + time.scrollWidth <= time.clientWidth && + timeBox.left >= box.left && + timeBox.right <= box.right, + background: getComputedStyle(label).backgroundColor, + }; + }); +} + +async function hideCategories(page: Page, player: Locator) { + await page.locator('[data-test-id="context-hide-categories"]').click(); + await page.mouse.move(1, 1); + await expect + .poll(() => + player.evaluate((element) => { + const box = element.getBoundingClientRect(); + return box.right <= window.innerWidth && box.width > 300; + }) + ) + .toBe(true); +} + +async function expectLabelInsidePlayer( + controls: Locator, + expected: { title: string; time: string } +) { + await expect + .poll(async () => { + const placement = await labelPlacement(controls); + return ( + placement !== null && + placement.time === expected.time && + placement.fromLeft >= 0 && + placement.fromRight >= 0 + ); + }) + .toBe(true); + const placement = await labelPlacement(controls); + expect(placement).not.toBeNull(); + expect(placement?.title).toBe(expected.title); + expect(placement?.insideVertically).toBe(true); + // A 120-character title never fits whole: it ellipsizes, the time not. + expect(placement?.titleEllipsized).toBe(true); + expect(placement?.timeWhole).toBe(true); + expect(placement?.width).toBeLessThan(placement?.playerWidth ?? 0); + return placement; +} + +test('@epg @xtream @electron keyboard reaches the catch-up programme name and its label stays inside the player', async ({ + dataDir, + request, +}) => { + test.setTimeout(240000); + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + const template = messages('en').EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION; + + try { + const { player, longTitle } = await openChannel(page, request, null); + const { controls, slider } = await playCatchup(page, player, longTitle); + + for (const width of [1280, 800]) { + await page.setViewportSize({ width, height: 800 }); + if (width < 900) { + // Below the desktop window's minimum width the guide pushes + // the player off-screen; hiding the categories is how such a + // narrow layout gets a (narrow) player. + await hideCategories(page, player); + } + for (const theme of ['light', 'dark'] as const) { + await applyTheme(page, theme); + await tabToSeekBar(controls, slider); + + // Keyboard focus alone shows the label at the position. + await expect( + controls.locator( + '[data-test-id="player-controls-timeline-label"]' + ) + ).toBeVisible(); + + // The left end: the label is pinned inside the player. + await page.keyboard.press('Home'); + await expect(slider).toHaveAttribute( + 'aria-valuetext', + valueText(template, longTitle, '0:00') + ); + const start = await expectLabelInsidePlayer(controls, { + title: longTitle, + time: '0:00', + }); + expect(start?.background).toBe(LABEL_GLASS); + + // An arrow moves into the next second of the programme. + await page.keyboard.press('ArrowRight'); + await expect(slider).toHaveAttribute( + 'aria-valuetext', + valueText(template, longTitle, '0:01') + ); + await expectLabelInsidePlayer(controls, { + title: longTitle, + time: '0:01', + }); + + // The right end. + await page.keyboard.press('End'); + await expect(slider).toHaveAttribute( + 'aria-valuetext', + valueText(template, longTitle, '0:06') + ); + await expectLabelInsidePlayer(controls, { + title: longTitle, + time: '0:06', + }); + await page.screenshot({ + path: test.info().outputPath(`${theme}-${width}.png`), + }); + } + } + } finally { + await closeElectronApp(app); + } +}); + +for (const locale of ['de', 'ru'] as const) { + test(`@epg @xtream @electron names the catch-up programme and LIVE in the ${locale} locale`, async ({ + dataDir, + request, + }) => { + test.setTimeout(240000); + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + const player$ = messages(locale).EMBEDDED_MPV.PLAYER; + + try { + const { player, longTitle } = await openChannel( + page, + request, + locale + ); + // Live playback: the translated badge, announced by its name. + await expect( + player.getByRole('img', { + name: player$.LIVE_STREAM, + exact: true, + }) + ).toHaveText(player$.LIVE_BADGE, { timeout: 20000 }); + + const { controls, slider } = await playCatchup( + page, + player, + longTitle + ); + await page.setViewportSize({ width: 800, height: 800 }); + await hideCategories(page, player); + await tabToSeekBar(controls, slider); + await page.keyboard.press('Home'); + await expect(slider).toHaveAttribute( + 'aria-valuetext', + valueText(player$.TIMELINE_SEGMENT_POSITION, longTitle, '0:00') + ); + await expectLabelInsidePlayer(controls, { + title: longTitle, + time: '0:00', + }); + await page.screenshot({ + path: test.info().outputPath(`${locale}-800.png`), + }); + } finally { + await closeElectronApp(app); + } + }); +} diff --git a/apps/electron-backend-e2e/src/xtream-catchup-timeline.e2e.ts b/apps/electron-backend-e2e/src/xtream-catchup-timeline.e2e.ts index 57458dc6e..2ab656b03 100644 --- a/apps/electron-backend-e2e/src/xtream-catchup-timeline.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-catchup-timeline.e2e.ts @@ -65,8 +65,10 @@ test('@epg @xtream @electron draws the catch-up programme on the seek bar', asyn const bar = player.locator('.player-controls__timeline-bar'); await bar.hover(); await expect( - player.locator('[data-test-id="player-controls-timeline-label"]') - ).toContainText(`${PAST_PROGRAM} ·`); + player.locator( + '[data-test-id="player-controls-timeline-label-title"]' + ) + ).toHaveText(PAST_PROGRAM); await row.click(); await expect(titledSegments).toHaveCount(0, { timeout: 20000 }); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index c599ae9ac..1706031ac 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "رجوع 10 ثوانٍ", "FORWARD_10_SECONDS": "تقديم 10 ثوانٍ", "LIVE_STREAM": "بث مباشر", + "LIVE_BADGE": "مباشر", "PLAYBACK_POSITION": "موضع التشغيل", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "المدة غير معروفة", "MUTE": "كتم الصوت", "UNMUTE": "إلغاء كتم الصوت", "MUTED": "الصوت مكتوم", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3e317715f..4dd39160a 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "رجع 10 ثواني", "FORWARD_10_SECONDS": "قدّم 10 ثواني", "LIVE_STREAM": "بث مباشر", + "LIVE_BADGE": "مباشر", "PLAYBACK_POSITION": "موضع التشغيل", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "المدة ما معروفاش", "MUTE": "كتم الصوت", "UNMUTE": "رجّع الصوت", "MUTED": "الصوت مكتوم", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 948e7077a..67d95660c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Назад на 10 секунд", "FORWARD_10_SECONDS": "Наперад на 10 секунд", "LIVE_STREAM": "Прамая трансляцыя", + "LIVE_BADGE": "ЭФІР", "PLAYBACK_POSITION": "Пазіцыя прайгравання", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Працягласць невядомая", "MUTE": "Адключыць гук", "UNMUTE": "Уключыць гук", "MUTED": "Гук адключаны", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 89c6f78e2..8348a2890 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "10 Sekunden zurück", "FORWARD_10_SECONDS": "10 Sekunden vor", "LIVE_STREAM": "Live-Stream", + "LIVE_BADGE": "LIVE", "PLAYBACK_POSITION": "Wiedergabeposition", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Dauer unbekannt", "MUTE": "Stummschalten", "UNMUTE": "Stummschaltung aufheben", "MUTED": "Stummgeschaltet", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index fcbd90d74..47b67bd80 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Πίσω 10 δευτερόλεπτα", "FORWARD_10_SECONDS": "Μπροστά 10 δευτερόλεπτα", "LIVE_STREAM": "Ζωντανή ροή", + "LIVE_BADGE": "ΖΩΝΤΑΝΑ", "PLAYBACK_POSITION": "Θέση αναπαραγωγής", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Άγνωστη διάρκεια", "MUTE": "Σίγαση", "UNMUTE": "Κατάργηση σίγασης", "MUTED": "Σε σίγαση", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 3ad44ab4f..559276758 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Back 10 seconds", "FORWARD_10_SECONDS": "Forward 10 seconds", "LIVE_STREAM": "Live stream", + "LIVE_BADGE": "LIVE", "PLAYBACK_POSITION": "Playback position", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Duration unknown", "MUTE": "Mute", "UNMUTE": "Unmute", "MUTED": "Muted", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 9c9f450e8..e6e6c2a85 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Retroceder 10 segundos", "FORWARD_10_SECONDS": "Avanzar 10 segundos", "LIVE_STREAM": "Transmisión en vivo", + "LIVE_BADGE": "EN VIVO", "PLAYBACK_POSITION": "Posición de reproducción", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Duración desconocida", "MUTE": "Silenciar", "UNMUTE": "Activar sonido", "MUTED": "Silenciado", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4db7f7642..7b78d289e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Reculer de 10 secondes", "FORWARD_10_SECONDS": "Avancer de 10 secondes", "LIVE_STREAM": "Diffusion en direct", + "LIVE_BADGE": "DIRECT", "PLAYBACK_POSITION": "Position de lecture", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Durée inconnue", "MUTE": "Muet", "UNMUTE": "Activer le son", "MUTED": "Son coupé", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 136c5d2ac..78601e5ac 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Ugrás 10 másodperccel vissza", "FORWARD_10_SECONDS": "Ugrás 10 másodperccel előre", "LIVE_STREAM": "Élő adás", + "LIVE_BADGE": "ÉLŐ", "PLAYBACK_POSITION": "Lejátszási pozíció", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Ismeretlen időtartam", "MUTE": "Némítás", "UNMUTE": "Némítás feloldása", "MUTED": "Némítva", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e2504cc16..ae2051a4e 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Indietro di 10 secondi", "FORWARD_10_SECONDS": "Avanti di 10 secondi", "LIVE_STREAM": "Stream in diretta", + "LIVE_BADGE": "DIRETTA", "PLAYBACK_POSITION": "Posizione di riproduzione", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Durata sconosciuta", "MUTE": "Disattiva audio", "UNMUTE": "Attiva audio", "MUTED": "Audio disattivato", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 739da65f4..e61ade899 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "10秒戻る", "FORWARD_10_SECONDS": "10秒進む", "LIVE_STREAM": "ライブストリーム", + "LIVE_BADGE": "ライブ", "PLAYBACK_POSITION": "再生位置", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "再生時間不明", "MUTE": "ミュート", "UNMUTE": "ミュート解除", "MUTED": "ミュート中", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 92232644d..6137f5974 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "10초 뒤로", "FORWARD_10_SECONDS": "10초 앞으로", "LIVE_STREAM": "라이브 스트림", + "LIVE_BADGE": "라이브", "PLAYBACK_POSITION": "재생 위치", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "재생 시간 알 수 없음", "MUTE": "음소거", "UNMUTE": "음소거 해제", "MUTED": "음소거됨", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index d52090069..837266acc 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "10 seconden terug", "FORWARD_10_SECONDS": "10 seconden vooruit", "LIVE_STREAM": "Live stream", + "LIVE_BADGE": "LIVE", "PLAYBACK_POSITION": "Afspeelpositie", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Duur onbekend", "MUTE": "Dempen", "UNMUTE": "Dempen opheffen", "MUTED": "Gedempt", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 4e55beeb9..2d1dff181 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Cofnij o 10 sekund", "FORWARD_10_SECONDS": "Do przodu o 10 sekund", "LIVE_STREAM": "Transmisja na żywo", + "LIVE_BADGE": "NA ŻYWO", "PLAYBACK_POSITION": "Pozycja odtwarzania", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Nieznany czas trwania", "MUTE": "Wycisz", "UNMUTE": "Wyłącz wyciszenie", "MUTED": "Wyciszono", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 541599150..fb61ed26f 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Voltar 10 segundos", "FORWARD_10_SECONDS": "Avançar 10 segundos", "LIVE_STREAM": "Transmissão ao vivo", + "LIVE_BADGE": "AO VIVO", "PLAYBACK_POSITION": "Posição de reprodução", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Duração desconhecida", "MUTE": "Silenciar", "UNMUTE": "Ativar som", "MUTED": "Silenciado", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 0114559bd..484784633 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "Назад на 10 секунд", "FORWARD_10_SECONDS": "Вперёд на 10 секунд", "LIVE_STREAM": "Прямой эфир", + "LIVE_BADGE": "ЭФИР", "PLAYBACK_POSITION": "Позиция воспроизведения", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Длительность неизвестна", "MUTE": "Отключить звук", "UNMUTE": "Включить звук", "MUTED": "Звук отключён", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 97ba7300f..ab32301f5 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "10 saniye geri", "FORWARD_10_SECONDS": "10 saniye ileri", "LIVE_STREAM": "Canlı yayın", + "LIVE_BADGE": "CANLI", "PLAYBACK_POSITION": "Oynatma konumu", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "Süre bilinmiyor", "MUTE": "Sesi kapat", "UNMUTE": "Sesi aç", "MUTED": "Ses kapalı", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index b2492e5c3..96a82445b 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "快退 10 秒", "FORWARD_10_SECONDS": "快进 10 秒", "LIVE_STREAM": "直播", + "LIVE_BADGE": "直播", "PLAYBACK_POSITION": "播放位置", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "时长未知", "MUTE": "静音", "UNMUTE": "取消静音", "MUTED": "已静音", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 9bffe2cb3..47f3ea82a 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -615,7 +615,10 @@ "BACK_10_SECONDS": "倒轉 10 秒", "FORWARD_10_SECONDS": "快轉 10 秒", "LIVE_STREAM": "現場直播", + "LIVE_BADGE": "直播", "PLAYBACK_POSITION": "播放位置", + "TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", + "DURATION_UNKNOWN": "時長未知", "MUTE": "靜音", "UNMUTE": "取消靜音", "MUTED": "已靜音", diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md index 57641741a..15964acd2 100644 --- a/apps/xtream-mock-server/README.md +++ b/apps/xtream-mock-server/README.md @@ -115,6 +115,7 @@ media. Barriers and delays are coordination tools, not timing inputs. | `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active | | `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active | | `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active | +| `epglong` | `epglong` | EPG fixture, long title | 2 | 1 | 1 | 3 | active | | `tzoffset` | `tzoffset` | EPG fixture, `UTC+3` clock | 2 | 1 | 1 | 3 | active | | `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active | | `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active | diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts index 37877570c..aa7cc0b42 100644 --- a/apps/xtream-mock-server/src/app/data-store.ts +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -31,7 +31,11 @@ import { buildPerformanceSeriesInfo, buildPerformanceVodDetails, } from './generators/performance.generator.js'; -import { getScenario, ScenarioConfig } from './scenarios.js'; +import { + getScenario, + LONG_PROGRAMME_TITLE, + ScenarioConfig, +} from './scenarios.js'; export interface PortalData { scenario: ScenarioConfig; @@ -100,7 +104,11 @@ function generatePortalData(username: string, password: string): PortalData { const vodStreams = generateVodStreams(vodCategories, itemsPerCategory); const seriesItems = generateSeriesItems(seriesCategories, itemsPerCategory); if (scenario.epgFixture === 'timezone-focus') { - const timezoneFixture = buildTimezoneFixture(); + const timezoneFixture = buildTimezoneFixture( + scenario.longProgrammeTitle + ? LONG_PROGRAMME_TITLE + : 'Earlier Bulletin' + ); liveCategories = timezoneFixture.liveCategories; liveStreams = timezoneFixture.liveStreams; timezoneFixture.epgListingsByStreamId.forEach((listings, streamId) => { @@ -249,7 +257,7 @@ function detailCacheKey( return `${username}:${password}:${itemId}`; } -function buildTimezoneFixture(): Pick< +function buildTimezoneFixture(pastTitle: string): Pick< PortalData, 'liveCategories' | 'liveStreams' | 'epgListingsByStreamId' > { @@ -304,7 +312,7 @@ function buildTimezoneFixture(): Pick< ]; const epgListingsByStreamId = new Map<number, RawEpgListing[]>(); - epgListingsByStreamId.set(10_000, buildTimezoneNewsEpg(10_000)); + epgListingsByStreamId.set(10_000, buildTimezoneNewsEpg(10_000, pastTitle)); epgListingsByStreamId.set(10_001, []); epgListingsByStreamId.set(10_002, []); @@ -315,7 +323,10 @@ function buildTimezoneFixture(): Pick< }; } -function buildTimezoneNewsEpg(streamId: number): RawEpgListing[] { +function buildTimezoneNewsEpg( + streamId: number, + pastTitle: string +): RawEpgListing[] { const now = Math.floor(Date.now() / 1000); const roundedNow = now - (now % (15 * 60)); const nextUtcMidnight = getFutureUtcMidnight(now); @@ -325,7 +336,7 @@ function buildTimezoneNewsEpg(streamId: number): RawEpgListing[] { const listings = [ { id: `${streamId}-past`, - title: 'Earlier Bulletin', + title: pastTitle, description: 'Past schedule item used to anchor current-program detection.', startTimestamp: roundedNow - 75 * 60, diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 1ab1949a0..7878733c0 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -13,6 +13,12 @@ export interface ScenarioConfig { expiryDate: string; /** Optional deterministic EPG fixture profile for scenario-specific tests. */ epgFixture?: 'timezone-focus'; + /** + * With `epgFixture`, the past programme carries + * {@link LONG_PROGRAMME_TITLE} instead of its short title, for the + * catch-up seek bar's label bounds. + */ + longProgrammeTitle?: true; /** Large, deliberately reordered categories for sidebar scroll coverage. */ categoryFixture?: 'scroll'; /** @@ -50,6 +56,10 @@ export interface ScenarioConfig { silentActions?: readonly string[]; } +/** A 120-character programme title for label overflow tests. */ +export const LONG_PROGRAMME_TITLE = + 'Extended Late Night Roundtable With Viewer Questions, Studio Guests and a Closing Summary of Every Story Covered Tonight'; + /** * Predefined scenarios keyed by "username:password". * Auth is username+password for Xtream (not MAC address like Stalker). @@ -175,6 +185,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = { expiryDate: '2099-12-31', epgFixture: 'timezone-focus', }, + 'epglong:epglong': { + name: 'epg-fixture-long-title', + description: + 'The EPG fixture with a 120-character title on the past programme, whose catch-up plays the local clip', + seed: 6006, + categoryCount: { live: 2, vod: 1, series: 1 }, + itemsPerCategory: 3, + seasonsPerSeries: 1, + episodesPerSeason: 3, + accountStatus: 'Active', + expiryDate: '2099-12-31', + epgFixture: 'timezone-focus', + longProgrammeTitle: true, + }, 'tzoffset:tzoffset': { name: 'epg-fixture-offset-clock', description: diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index be65ea573..9ce71d931 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -1,6 +1,7 @@ import { connect } from 'node:net'; import express from 'express'; import { resetAll } from './data-store.js'; +import { LONG_PROGRAMME_TITLE } from './scenarios.js'; import { createXtreamMockApp, parseXtreamMockServerEnvironment, @@ -76,6 +77,37 @@ describe('Xtream mock server factory', () => { } }); + it('serves the long-title EPG scenario beside the unchanged epg one', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + const titles = async (user: string) => { + const response = await fetch( + `${running.origin}/player_api.php?username=${user}&password=${user}&action=get_simple_data_table&stream_id=10000` + ); + const body = (await response.json()) as { + epg_listings: { title: string }[]; + }; + return body.epg_listings.map((listing) => + Buffer.from(listing.title, 'base64').toString('utf8') + ); + }; + try { + expect(LONG_PROGRAMME_TITLE).toHaveLength(120); + expect(await titles('epglong')).toEqual([ + LONG_PROGRAMME_TITLE, + 'Global Headlines', + 'Market Wrap', + 'Overnight Update', + 'Late Edition', + 'After Midnight', + ]); + expect((await titles('epg'))[0]).toBe('Earlier Bulletin'); + } finally { + await running.close(); + } + }); + it('keeps performance controls absent unless explicitly enabled', async () => { const running = await startLoopbackServer( createXtreamMockApp({ host: '127.0.0.1', port: 0 }) diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index df2f0beab..4e24f7341 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -51,6 +51,8 @@ https://example.channels/path-to-file/3.m3u8 https://example.channels/path-to-file/4.m3u8 `; const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; +/** EPG-fixture users whose timeshift URLs play the local MPEG-TS clip. */ +const TIMESHIFT_CLIP_USERS = new Set(['epg', 'epglong']); // Loopback by default: the fixtures serve fabricated but unauthenticated // content, so they should not be reachable from other hosts unless a dev // explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them). @@ -323,7 +325,7 @@ function installStreamRoutes( app.all( '/timeshift/:username/:password/:duration/:start/:streamId.ts', (request, response) => { - if (request.params['username'] === 'epg') { + if (TIMESHIFT_CLIP_USERS.has(request.params['username'])) { response .type('video/mp2t') .send( diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 6924e2712..38bc5d7fd 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -288,9 +288,11 @@ It owns only transient presentation behavior: - `ControlsShortcuts` — document keyboard routing; - `ControlsSurface` — pointer/click/double-click surface interactions; - `ControlsTimeline` — scrub state and timeline projections; -- `ControlsTimelineHover` — the time under the pointer over the timeline; +- `ControlsTimelineLabel` — where the timeline label points: the time under + a hovering pointer, else the slider value while the keyboard or a drag + moves it, plus the pure `clampTimelineLabelLeft` placement; - `app-player-timeline` — presentation of the timeline row (current time, - segment track, knob, hover label, remaining time / LIVE, recording + segment track, knob, 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; @@ -314,7 +316,10 @@ primary action and progress, cyan `#5cd6ff` for "something is on", violet `#6b7384` text ramp, and two reds: `--pc-live` `#d32f2f` fills the LIVE badge (white label 5.0:1), and `--pc-danger` `#ff5252` colours the active record glyph and the recording status (6.2:1 on the glass over a black -frame). They are literal on purpose: the overlay is +frame). The seek bar's unplayed track is the opaque `--pc-timeline-track` +`#313437` and its segment separators are `--pc-timeline-separator` white, +so their contrast does not depend on the frame (see Timeline segments). +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. Watch progress reads `--pc-progress`, the accent blue declared by the @@ -327,13 +332,20 @@ one colour in every player surface (UI guidelines, "Watch progress colour"). 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 badge text is the translated `LIVE_BADGE`, and both it and the + `--:--` placeholder (`UNKNOWN_TIME_TEXT`) are `role="img"` elements named + `LIVE_STREAM` / `DURATION_UNKNOWN`: a name on a role-less span is never + announced, and the dashes are not words. The `<input type="range">` stays as the interaction and accessibility layer, invisible and full-size over the drawn track: dragging, arrow keys, `aria-valuetext` and the focus ring (drawn on the track through `:has(:focus-visible)`) all belong to it, so scrubbing semantics are unchanged. Hovering the bar with a mouse shows a white marker and a - `1:40` label above the pointer (`ControlsTimelineHover`); touch never - hovers and a non-seekable timeline never labels. + `1:40` label above the pointer. Keyboard focus on the slider + (`:focus-visible`, or any seek key) and a drag preview — the only way + touch, which never hovers, gets it — show the same label at the slider + value instead, without the marker (`ControlsTimelineLabel`; a hovering + pointer wins). 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 @@ -359,13 +371,12 @@ one colour in every player surface (UI guidelines, "Watch progress colour"). ### 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 +The track is drawn as a row of segments, each with 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 +start percent, `width` = share minus the 2px 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 +and the 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 @@ -374,9 +385,32 @@ the previous end and fills every gap with an untitled segment so the row always covers `[0, duration]`. Without segments — live playback, VOD and series — 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` +current scrub or playback value; the label becomes `Chapter 2 · 12:40` over a titled segment. mpv's chapter list is not a producer yet. +Boundaries hold 3:1 over any frame. Segments only round the bar's two +ends, and every gap (2px, `TIMELINE_SEGMENT_GAP_PX`) is filled by a +separator (`separatorLeft`) in `--pc-timeline-separator` white: 12.5:1 on +the opaque `--pc-timeline-track`, 3.2:1 on the `--pc-progress` fill, which +keeps 4.0:1 on the track. The former translucent track with see-through +gaps measured 1.3–1.6:1 at its boundaries, and its fill fell to 1.1:1 over +a bright frame (`player-controls.palette.spec.ts` pins the ratios). + +The names reach every user, whichever source drew the segments. The +slider's `aria-valuetext` is the translated `TIMELINE_SEGMENT_POSITION` +(`{{title}} · {{time}}`) whenever the scrub or playback value — the +keyboard's target included — lies in a titled segment +(`ControlsTimeline.segmentTitle`), and the plain time otherwise. The visible +label (`aria-hidden`; it mirrors the valuetext) is two parts: the title +ellipsizes at `min(320px, 60cqw)` and the time never truncates. The +stylesheet centres it on its anchor; once laid out, +`PlayerTimelineComponent` moves it by its measured width +(`clampTimelineLabelLeft`, an `afterRenderEffect`) so it stays 8px inside +the player (`.player-controls-host`) — past the bar's ends when needed, as +the bar is narrower than the player. `xtream-catchup-timeline-label.e2e.ts` +tabs to the bar with a 120-character programme title at 1280 and 800px, in +both themes and in de and ru. + **Catch-up producer.** Archive playback of a live channel passes the EPG programmes overlapping its archive window. `buildCatchupTimelineSegments(programmes, activeProgramme, windowEnd?)` @@ -1661,7 +1695,7 @@ libs/ui/playback/src/lib/player-controls/ ├── controls-feedback.ts ├── controls-format.utils.ts ├── controls-layout.ts -├── controls-timeline-hover.ts +├── controls-timeline-label.ts ├── controls-timeline-segments.ts ├── controls-settings.ts ├── controls-settings-groups.ts diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index a5b068544..28f9ffbe9 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -401,6 +401,7 @@ sometimes only respond to that misspelled action. | `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active | | `minimal:minimal` | 3003 | 2 each | 5 | active | | `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active | +| `epglong:epglong` | 6006 | live:2, vod:1, series:1 | 3 | active | | `tzoffset:tzoffset` | 6006 | live:2, vod:1, series:1 | 3 | active | | `emptyvod:emptyvod` | 7007 | 2 each | 5 | active | | `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active | @@ -421,6 +422,12 @@ That deliberate mismatch lets Electron tests verify the renderer uses timestamp fields for sorting, current-program selection, progress bars, and local clock labels instead of trusting provider-local strings. +Timeshift URLs of `epg` and `epglong` play the local MPEG-TS clip +(`src/fixtures/live.mpegts`). `epglong:epglong` is the same fixture with +`LONG_PROGRAMME_TITLE`, a 120-character title, on the past programme, for +the catch-up seek bar's label bounds; `epg:epg` keeps its short titles, +which other tests assert verbatim. + ### `marketing:marketing` fixture details This scenario is reserved for release screenshots and marketing materials: 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 62f36d072..9e18d1b36 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 @@ -264,6 +264,7 @@ } @else if (isLivePlayback()) { <span class="embedded-mpv-player__live-badge" + role="img" [attr.aria-label]=" 'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate @@ -273,10 +274,17 @@ class="embedded-mpv-player__live-dot" aria-hidden="true" ></span> - LIVE + {{ 'EMBEDDED_MPV.PLAYER.LIVE_BADGE' | translate }} </span> } @else { - <span>--:--</span> + <span + role="img" + [attr.aria-label]=" + 'EMBEDDED_MPV.PLAYER.DURATION_UNKNOWN' + | translate + " + >--:--</span + > } </div> @if (recordingStatusText(); as recordingStatus) { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts index 20cd0609b..6562853e0 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.spec.ts @@ -192,11 +192,12 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => { By.css('[data-test-id="embedded-mpv-next-episode"]') ) ).toBeNull(); - expect( - fixture.debugElement.query( - By.css('.embedded-mpv-player__live-badge') - ) - ).not.toBeNull(); + const liveBadge = fixture.debugElement.query( + By.css('.embedded-mpv-player__live-badge') + ); + expect(liveBadge).not.toBeNull(); + // Named on an element with a role, so the name is announced. + expect(liveBadge.attributes['role']).toBe('img'); expect( fixture.debugElement.query( By.css( @@ -366,6 +367,13 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => { ) ).toBeNull(); expect(fixture.nativeElement.textContent).toContain('--:--'); + expect( + fixture.debugElement.query( + By.css( + '.embedded-mpv-player__time [role="img"][aria-label="EMBEDDED_MPV.PLAYER.DURATION_UNKNOWN"]' + ) + )?.nativeElement.textContent + ).toBe('--:--'); }); it('offers Retry when a live stream ends without a reconnect in progress', () => { 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 86b104650..88df8e209 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,14 @@ export function formatTime(value: number | null | undefined): string { return `${minutes}:${String(seconds).padStart(2, '0')}`; } +/** + * The end-time slot while no duration is known: {@link formatTime}'s shape + * with the digits blanked, no more a language string than `1:40` is. The + * docks pair it with the translated `DURATION_UNKNOWN` name for assistive + * technology, which would otherwise read out the dashes. + */ +export const UNKNOWN_TIME_TEXT = '--:--'; + /** * 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. 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 deleted file mode 100644 index 6ce9d5106..000000000 --- a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 6d2fa321c..000000000 --- a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Signal, computed, signal } from '@angular/core'; -import { formatTime } from './controls-format.utils'; -import { - findTimelineSegment, - type TimelineSegmentView, -} from './controls-timeline-segments'; - -/** - * Projects a pointer's horizontal position over the timeline bar onto a - * playback time (seconds), clamped to the bar. Null when the bar has no - * width or the duration is unknown. - */ -export function projectPointerToSeconds( - clientX: number, - rect: Pick<DOMRect, 'left' | 'width'>, - durationSeconds: number -): number | null { - if ( - !Number.isFinite(clientX) || - !Number.isFinite(rect.width) || - rect.width <= 0 || - !Number.isFinite(durationSeconds) || - durationSeconds <= 0 - ) { - return null; - } - const fraction = Math.min( - 1, - Math.max(0, (clientX - rect.left) / rect.width) - ); - return fraction * durationSeconds; -} - -export interface ControlsTimelineHoverDeps { - duration: Signal<number>; - /** Whether the timeline accepts pointer interaction at all. */ - interactive: Signal<boolean>; - /** Drawn segments, for the title in the label; none means time only. */ - segments?: Signal<readonly TimelineSegmentView[]>; -} - -/** - * Hover state of the timeline: the time under the pointer, its position as - * a percentage of the bar (for the marker and label), and the label text. - * Touch never hovers, so a coarse pointer leaves this empty. - */ -export class ControlsTimelineHover { - readonly seconds = signal<number | null>(null); - - constructor(private readonly deps: ControlsTimelineHoverDeps) {} - - readonly percent = computed(() => { - const seconds = this.seconds(); - const duration = this.deps.duration(); - if (seconds === null || duration <= 0) { - return null; - } - return Math.min(100, Math.max(0, (seconds / duration) * 100)); - }); - - /** The titled segment under the pointer, if any. */ - readonly segmentTitle = computed(() => { - const seconds = this.seconds(); - const segments = this.deps.segments?.(); - if (seconds === null || !segments) { - return null; - } - return findTimelineSegment(segments, seconds)?.title ?? null; - }); - - /** `Chapter 2 · 12:40` over a titled segment, else the time alone. */ - readonly label = computed(() => { - const seconds = this.seconds(); - if (seconds === null) { - return null; - } - const time = formatTime(seconds); - const title = this.segmentTitle(); - return title ? `${title} \u00b7 ${time}` : time; - }); - - /** Bound to `pointermove` on the bar element itself (`currentTarget`). */ - move(event: PointerEvent, bar?: HTMLElement): void { - const target = bar ?? event.currentTarget; - if ( - !(target instanceof HTMLElement) || - !this.deps.interactive() || - event.pointerType === 'touch' - ) { - this.clear(); - return; - } - this.seconds.set( - projectPointerToSeconds( - event.clientX, - target.getBoundingClientRect(), - this.deps.duration() - ) - ); - } - - clear(): void { - if (this.seconds() !== null) { - this.seconds.set(null); - } - } -} diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-label.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-label.spec.ts new file mode 100644 index 000000000..637be1106 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-label.spec.ts @@ -0,0 +1,309 @@ +import { signal } from '@angular/core'; +import { buildCatchupTimelineSegments } from './catchup-timeline-segments'; +import { + ControlsTimelineLabel, + TIMELINE_LABEL_EDGE_PX, + clampTimelineLabelLeft, + projectPointerToSeconds, +} from './controls-timeline-label'; +import { normalizeTimelineSegments } from './controls-timeline-segments'; +import type { PlayerTimelineSegment } from './player-controls.model'; + +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('clampTimelineLabelLeft', () => { + // A 1280px player whose bar runs from x=90 to x=1190: the current and + // remaining times flank it. + const player = { left: 0, right: 1280 }; + const bar = { left: 90, width: 1100 }; + const edge = TIMELINE_LABEL_EDGE_PX; + + it('centres the label on its anchor when it fits', () => { + // Anchor at 550px into the bar; a 200px label starts 100px before. + expect(clampTimelineLabelLeft(50, 200, bar, player)).toBe(450); + }); + + it('lets the label pass the bar ends but not the player edges', () => { + // At the bar's start the centred label would begin at -100px (bar + // coordinates), i.e. 10px left of the player: pinned to its edge. + expect(clampTimelineLabelLeft(0, 200, bar, player)).toBe( + -bar.left + edge + ); + // A narrower label centred there still fits past the bar's start. + expect(clampTimelineLabelLeft(0, 60, bar, player)).toBe(-30); + // At the bar's end the right edge is pinned to the player's. + const left = clampTimelineLabelLeft(100, 300, bar, player); + expect(bar.left + left + 300).toBe(player.right - edge); + }); + + it('keeps a measured 120-character title label inside narrow players', () => { + // The title caps at min(320px, 60cqw); with the time and padding an + // 800px player's label measures about 390px. + const narrow = { left: 0, right: 800 }; + const narrowBar = { left: 70, width: 660 }; + for (const percent of [0, 3, 50, 97, 100]) { + const left = clampTimelineLabelLeft(percent, 390, narrowBar, narrow); + const start = narrowBar.left + left; + expect(start).toBeGreaterThanOrEqual(narrow.left + edge); + expect(start + 390).toBeLessThanOrEqual(narrow.right - edge); + } + }); + + it('starts a label wider than the player at its left edge', () => { + const tiny = { left: 0, right: 200 }; + expect( + clampTimelineLabelLeft(50, 400, { left: 40, width: 120 }, tiny) + ).toBe(-40 + edge); + }); +}); + +describe('ControlsTimelineLabel', () => { + function createLabel(options: { + duration?: number; + interactive?: boolean; + segments?: readonly PlayerTimelineSegment[] | null; + } = {}) { + const duration = signal(options.duration ?? 600); + const interactive = signal(options.interactive ?? true); + const value = signal(0); + const scrubbing = signal(false); + const label = new ControlsTimelineLabel({ + duration, + interactive, + segments: signal( + normalizeTimelineSegments(options.segments, duration()) + ), + value, + scrubbing, + }); + const bar = document.createElement('div'); + bar.getBoundingClientRect = () => ({ left: 0, width: 400 }) as DOMRect; + return { label, bar, duration, interactive, value, scrubbing }; + } + + function pointer(clientX: number, pointerType = 'mouse'): PointerEvent { + return { clientX, pointerType } as PointerEvent; + } + + /** A focus event whose target answers `:focus-visible` as given. */ + function focusEvent(visible: boolean): FocusEvent { + const slider = document.createElement('input'); + Object.defineProperty(slider, 'matches', { + value: (selector: string) => + selector === ':focus-visible' ? visible : false, + }); + return { target: slider } as unknown as FocusEvent; + } + + function key(name: string): KeyboardEvent { + return { key: name } as KeyboardEvent; + } + + it('reports the hovered time, percent, marker and text', () => { + const { label, bar } = createLabel(); + + label.move(pointer(100), bar); + + expect(label.seconds()).toBe(150); + expect(label.percent()).toBe(25); + expect(label.markerPercent()).toBe(25); + expect(label.text()).toEqual({ title: null, time: '2:30' }); + }); + + it('clears on leave', () => { + const { label, bar } = createLabel(); + label.move(pointer(100), bar); + + label.clear(); + + expect(label.seconds()).toBeNull(); + expect(label.percent()).toBeNull(); + expect(label.text()).toBeNull(); + }); + + it('ignores touch hovers and non-interactive timelines', () => { + const { label, bar, interactive } = createLabel(); + + label.move(pointer(100, 'touch'), bar); + expect(label.seconds()).toBeNull(); + + interactive.set(false); + label.move(pointer(100), bar); + expect(label.seconds()).toBeNull(); + }); + + it('reads the bar from the event target when none is passed', () => { + const { label, bar } = createLabel(); + + label.move({ ...pointer(200), currentTarget: bar } as PointerEvent); + expect(label.seconds()).toBe(300); + + label.move({ ...pointer(200), currentTarget: null } as PointerEvent); + expect(label.seconds()).toBeNull(); + }); + + it('drops a stale label once the duration disappears', () => { + const { label, bar, duration } = createLabel(); + label.move(pointer(100), bar); + + duration.set(0); + + expect(label.percent()).toBeNull(); + expect(label.text()).toBeNull(); + }); + + describe('keyboard', () => { + it('labels the slider value on keyboard focus, without a marker', () => { + const { label, value } = createLabel(); + value.set(90); + + label.focus(focusEvent(true)); + + expect(label.percent()).toBe(15); + expect(label.text()).toEqual({ title: null, time: '1:30' }); + expect(label.markerPercent()).toBeNull(); + + value.set(95); + expect(label.text()?.time).toBe('1:35'); + + label.blur(); + expect(label.text()).toBeNull(); + }); + + it('stays hidden on pointer focus until a seek key arrives', () => { + const { label, value } = createLabel(); + value.set(60); + + label.focus(focusEvent(false)); + expect(label.text()).toBeNull(); + + label.keydown(key('Tab')); + expect(label.text()).toBeNull(); + + label.keydown(key('ArrowRight')); + expect(label.text()?.time).toBe('1:00'); + }); + + it('treats focus as visible where :focus-visible is unsupported', () => { + const { label } = createLabel(); + const slider = document.createElement('input'); + Object.defineProperty(slider, 'matches', { + value: () => { + throw new SyntaxError('unknown pseudo-class'); + }, + }); + + label.focus({ target: slider } as unknown as FocusEvent); + + expect(label.keyboard()).toBe(true); + }); + + it('lets a hovering pointer take over, then returns to the value', () => { + const { label, bar, value } = createLabel(); + value.set(60); + label.keydown(key('End')); + + label.move(pointer(200), bar); + expect(label.text()?.time).toBe('5:00'); + + label.clear(); + expect(label.text()?.time).toBe('1:00'); + }); + + it('never labels a non-interactive timeline', () => { + const { label, interactive } = createLabel(); + label.keydown(key('ArrowLeft')); + + interactive.set(false); + + expect(label.text()).toBeNull(); + }); + }); + + it('follows a drag preview, which is how touch users see it', () => { + const { label, bar, value, scrubbing } = createLabel(); + label.move(pointer(100, 'touch'), bar); + value.set(300); + + scrubbing.set(true); + expect(label.text()?.time).toBe('5:00'); + expect(label.markerPercent()).toBeNull(); + + scrubbing.set(false); + expect(label.text()).toBeNull(); + }); + + describe('segment titles', () => { + it('names catch-up programmes', () => { + // A 60-minute archive window holding two programmes. + const programme = (title: string, start: number, stop: number) => ({ + title, + start: '', + stop: '', + startTimestamp: start, + stopTimestamp: stop, + }); + const active = programme('Morning Report', 10_000, 11_800); + const segments = buildCatchupTimelineSegments( + [active, programme('Weather', 11_800, 13_600)], + active, + 13_600 + ); + const { label, value } = createLabel({ + duration: 3600, + segments, + }); + label.keydown(key('ArrowRight')); + + value.set(600); + expect(label.text()).toEqual({ + title: 'Morning Report', + time: '10:00', + }); + value.set(1800); + expect(label.text()?.title).toBe('Weather'); + }); + + it('names file chapters and leaves untitled ones to the time', () => { + // Chapters as Embedded MPV reports them: each runs to the next + // one's start, the last to the end; an untitled one has no name. + const { label, value } = createLabel({ + duration: 600, + segments: [ + { startSeconds: 0, endSeconds: 90, title: 'Opening' }, + { startSeconds: 90, endSeconds: 480, title: null }, + { startSeconds: 480, endSeconds: 600, title: 'Credits' }, + ], + }); + label.keydown(key('Home')); + + value.set(30); + expect(label.text()).toEqual({ title: 'Opening', time: '0:30' }); + value.set(200); + expect(label.text()).toEqual({ title: null, time: '3:20' }); + value.set(600); + expect(label.text()?.title).toBe('Credits'); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-label.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-label.ts new file mode 100644 index 000000000..9ebea80c2 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-label.ts @@ -0,0 +1,202 @@ +import { Signal, computed, signal } from '@angular/core'; +import { formatTime } from './controls-format.utils'; +import { + findTimelineSegment, + type TimelineSegmentView, +} from './controls-timeline-segments'; + +/** + * Projects a pointer's horizontal position over the timeline bar onto a + * playback time (seconds), clamped to the bar. Null when the bar has no + * width or the duration is unknown. + */ +export function projectPointerToSeconds( + clientX: number, + rect: Pick<DOMRect, 'left' | 'width'>, + durationSeconds: number +): number | null { + if ( + !Number.isFinite(clientX) || + !Number.isFinite(rect.width) || + rect.width <= 0 || + !Number.isFinite(durationSeconds) || + durationSeconds <= 0 + ) { + return null; + } + const fraction = Math.min( + 1, + Math.max(0, (clientX - rect.left) / rect.width) + ); + return fraction * durationSeconds; +} + +/** Room (px) the label keeps from the player's left and right edges. */ +export const TIMELINE_LABEL_EDGE_PX = 8; + +/** + * Left edge (px, from the bar's left) of a label `labelWidth` wide: centred + * on the anchor at `percent` of the bar, but kept inside the player with + * {@link TIMELINE_LABEL_EDGE_PX} to spare on each side. The bar is narrower + * than the player (the times flank it), so the label may extend past the + * bar's ends. A label wider than the room starts at the player's left edge. + */ +export function clampTimelineLabelLeft( + percent: number, + labelWidth: number, + bar: Pick<DOMRect, 'left' | 'width'>, + player: Pick<DOMRect, 'left' | 'right'> +): number { + const centred = (percent / 100) * bar.width - labelWidth / 2; + const min = player.left - bar.left + TIMELINE_LABEL_EDGE_PX; + const max = player.right - bar.left - TIMELINE_LABEL_EDGE_PX - labelWidth; + return Math.max(min, Math.min(centred, max)); +} + +/** Keys a focused range input moves its value with. */ +const SEEK_KEYS = new Set([ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'Home', + 'End', + 'PageUp', + 'PageDown', +]); + +/** `:focus-visible`; focus counts as visible where the selector is unknown. */ +function hasVisibleFocus(target: EventTarget | null): boolean { + if (!(target instanceof Element)) { + return false; + } + try { + return target.matches(':focus-visible'); + } catch { + return true; + } +} + +export interface ControlsTimelineLabelDeps { + duration: Signal<number>; + /** Whether the timeline accepts interaction at all. */ + interactive: Signal<boolean>; + /** Drawn segments, for the title in the label; none means time only. */ + segments?: Signal<readonly TimelineSegmentView[]>; + /** The slider value: the scrub preview, else the playback position. */ + value?: Signal<number>; + /** A drag is previewing a position (see `ControlsTimeline`). */ + scrubbing?: Signal<boolean>; +} + +/** The label's parts: the segment title, when there is one, and the time. */ +export interface TimelineLabelText { + title: string | null; + time: string; +} + +/** + * The timeline label and where it points: the time under a hovering mouse + * or pen; otherwise the slider value while the keyboard drives the slider + * (keyboard focus or a seek key) or a drag previews a position — touch never + * hovers, so a touch drag is how touch users see it. A non-interactive + * timeline never labels. + */ +export class ControlsTimelineLabel { + /** Time under a hovering mouse or pen. */ + readonly pointerSeconds = signal<number | null>(null); + /** The keyboard holds the slider. */ + readonly keyboard = signal(false); + + constructor(private readonly deps: ControlsTimelineLabelDeps) {} + + readonly seconds = computed(() => { + if (!this.deps.interactive()) { + return null; + } + const pointer = this.pointerSeconds(); + if (pointer !== null) { + return pointer; + } + const followsValue = this.keyboard() || !!this.deps.scrubbing?.(); + return followsValue ? (this.deps.value?.() ?? null) : null; + }); + + 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 pointer's marker; for the keyboard and drags the knob is there. */ + readonly markerPercent = computed(() => + this.pointerSeconds() === null ? null : this.percent() + ); + + /** The titled segment the label points into, 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; + }); + + /** `{ title: 'Chapter 2', time: '12:40' }`, or the time alone. */ + readonly text = computed<TimelineLabelText | null>(() => { + const seconds = this.seconds(); + if (seconds === null || this.percent() === null) { + return null; + } + return { title: this.segmentTitle(), time: formatTime(seconds) }; + }); + + /** 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.pointerSeconds.set( + projectPointerToSeconds( + event.clientX, + target.getBoundingClientRect(), + this.deps.duration() + ) + ); + } + + clear(): void { + if (this.pointerSeconds() !== null) { + this.pointerSeconds.set(null); + } + } + + /** + * Bound to the slider's `focus`. Only keyboard focus shows the label: a + * pointer that focuses the slider labels through hover or the drag. + */ + focus(event: FocusEvent): void { + this.keyboard.set(hasVisibleFocus(event.target)); + } + + blur(): void { + this.keyboard.set(false); + } + + /** Bound to the slider's `keydown`: a seek key hands it to the keyboard. */ + keydown(event: KeyboardEvent): void { + if (SEEK_KEYS.has(event.key)) { + this.keyboard.set(true); + } + } +} 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 index 54baa007d..2bc105a59 100644 --- 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 @@ -68,20 +68,30 @@ describe('normalizeTimelineSegments', () => { ).toBeCloseTo(1); }); - it('places segments at their time positions with a gap after all but the last', () => { + it('places segments at their time positions with a separated gap after all but the last', () => { const segments = normalizeTimelineSegments( [ { startSeconds: 0, endSeconds: 150, title: 'A' }, - { startSeconds: 150, endSeconds: 600, title: 'B' }, + { startSeconds: 150, endSeconds: 450, title: 'B' }, ], 600 ); - expect(segments.map((s) => s.startPercent)).toEqual([0, 25]); + expect(segments.map((s) => s.startPercent)).toEqual([0, 25, 75]); expect(segments.map((s) => s.width)).toEqual([ - 'max(0px, calc(25% - 3px))', - '75%', + 'max(0px, calc(25% - 2px))', + 'max(0px, calc(50% - 2px))', + '25%', ]); + // The separator fills the gap, ending where the next segment starts. + expect(segments.map((s) => s.separatorLeft)).toEqual([ + 'calc(25% - 2px)', + 'calc(75% - 2px)', + null, + ]); + expect(normalizeTimelineSegments(null, 600)[0].separatorLeft).toBe( + null + ); }); it('clamps to the duration, cuts overlaps and drops empty segments', () => { 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 index b3003d000..e973ab6f8 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts @@ -11,10 +11,16 @@ export interface TimelineSegmentView { startPercent: number; /** CSS width: the share, minus the gap every segment but the last keeps. */ width: string; + /** + * CSS `left` of the separator drawn in the gap after this segment, so a + * boundary keeps its contrast whatever the frame behind it; null for + * the last segment, which has no gap. + */ + separatorLeft: string | null; } -/** Visual gap (px) after every segment except the last. */ -export const TIMELINE_SEGMENT_GAP_PX = 3; +/** Gap (px) after every segment except the last; its separator fills it. */ +export const TIMELINE_SEGMENT_GAP_PX = 2; const WHOLE_TIMELINE: TimelineSegmentView = { startSeconds: 0, @@ -23,6 +29,7 @@ const WHOLE_TIMELINE: TimelineSegmentView = { share: 1, startPercent: 0, width: '100%', + separatorLeft: null, }; /** @@ -86,13 +93,17 @@ export function normalizeTimelineSegments( const share = (segment.endSeconds - segment.startSeconds) / durationSeconds; const last = index === cover.length - 1; + const gap = `${TIMELINE_SEGMENT_GAP_PX}px`; return { ...segment, share, startPercent: (segment.startSeconds / durationSeconds) * 100, width: last ? `${share * 100}%` - : `max(0px, calc(${share * 100}% - ${TIMELINE_SEGMENT_GAP_PX}px))`, + : `max(0px, calc(${share * 100}% - ${gap}))`, + separatorLeft: last + ? null + : `calc(${(segment.endSeconds / durationSeconds) * 100}% - ${gap})`, }; }); } 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 985a62af5..da802d3c6 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,6 @@ import { Signal, computed, signal } from '@angular/core'; import { + findTimelineSegment, normalizeTimelineSegments, segmentFillPercent, type TimelineSegmentView, @@ -50,6 +51,14 @@ export class ControlsTimeline { normalizeTimelineSegments(this.hostSegments(), this.duration()) ); + /** + * Title of the segment holding the current (scrub or playback) value — + * the programme or chapter the slider names in its `aria-valuetext`. + */ + readonly segmentTitle = computed( + () => findTimelineSegment(this.segments(), this.value())?.title ?? null + ); + /** Played share of one segment for the current (scrub or playback) value. */ fillPercent(segment: TimelineSegmentView): number { if (!this.state().canSeek) { 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 index e57fed837..c4e8b8886 100644 --- 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 @@ -57,6 +57,10 @@ describe('PlayerControlsComponent dock', () => { const query = (selector: string) => fixture.nativeElement.querySelector(selector) as HTMLElement | null; + const labelPart = (part: 'title' | 'time') => + query(`[data-test-id="player-controls-timeline-label-${part}"]`) + ?.textContent ?? null; + const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) => fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); @@ -110,6 +114,9 @@ describe('PlayerControlsComponent dock', () => { FORWARD_10_SECONDS: 'Forward 10 seconds', MUTE: 'Mute', UNMUTE: 'Unmute', + LIVE_STREAM: 'Live stream', + LIVE_BADGE: 'LIVE', + DURATION_UNKNOWN: 'Duration unknown', }, }, }); @@ -221,14 +228,22 @@ describe('PlayerControlsComponent dock', () => { 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( - '--:--' + const placeholder = query('.player-controls__time--end'); + expect(placeholder?.textContent).toBe('--:--'); + // Assistive technology hears the name, not the dashes. + expect(placeholder?.getAttribute('role')).toBe('img'); + expect(placeholder?.getAttribute('aria-label')).toBe( + 'Duration unknown' ); setState({ canSeek: false, isLive: true }); fixture.detectChanges(); expect(query('.player-controls__time--end')).toBeNull(); - expect(query('.player-controls__live-badge')).not.toBeNull(); + const badge = query('.player-controls__live-badge'); + expect(badge?.textContent?.trim()).toBe('LIVE'); + // A name on a role-less span is never announced. + expect(badge?.getAttribute('role')).toBe('img'); + expect(badge?.getAttribute('aria-label')).toBe('Live stream'); }); it('draws the progress fill and knob from the timeline progress', () => { @@ -281,7 +296,7 @@ describe('PlayerControlsComponent dock', () => { '[data-test-id="player-controls-timeline-label"]' ); expect(label?.textContent?.trim()).toBe('1:40'); - expect(label?.style.getPropertyValue('--hover-x')).toBe('25%'); + expect(label?.style.getPropertyValue('--anchor-x')).toBe('25%'); expect(query('.player-controls__timeline-marker')?.style.left).toBe( '25%' ); @@ -324,6 +339,14 @@ describe('PlayerControlsComponent dock', () => { '75%', ]); expect(segments.at(-1)?.style.width).toBe('25%'); + // A separator fills the gap before every following segment. + expect( + Array.from( + fixture.nativeElement.querySelectorAll( + '.player-controls__timeline-separator' + ) as NodeListOf<HTMLElement> + ).map((separator) => separator.style.left) + ).toEqual(['calc(25% - 2px)', 'calc(75% - 2px)']); expect( segments.map( (s) => @@ -359,21 +382,115 @@ describe('PlayerControlsComponent dock', () => { 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'); + // Two parts, so a long title can ellipsize without the time. + expect(labelPart('title')).toBe('Intro'); + expect(labelPart('time')).toBe('1:40'); bar.dispatchEvent( new MouseEvent('pointermove', { clientX: 300, bubbles: true }) ); fixture.detectChanges(); + expect(labelPart('title')).toBeNull(); + expect(labelPart('time')).toBe('5:00'); + }); + + it('labels the keyboard position without a pointer marker', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 400, + positionSeconds: 100, + }); + fixture.componentRef.setInput('timelineSegments', [ + { startSeconds: 0, endSeconds: 200, title: 'Intro' }, + ]); + fixture.detectChanges(); + const slider = query( + '.player-controls__slider--timeline' + ) as HTMLInputElement; + Object.defineProperty(slider, 'matches', { + value: (selector: string) => selector === ':focus-visible', + }); + + slider.dispatchEvent(new FocusEvent('focus')); + fixture.detectChanges(); + expect(labelPart('title')).toBe('Intro'); + expect(labelPart('time')).toBe('1:40'); + expect(query('.player-controls__timeline-marker')).toBeNull(); + + slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' })); + slider.value = '400'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + expect(labelPart('title')).toBeNull(); + expect(labelPart('time')).toBe('6:40'); + // The step commits at once, as a key press does. + slider.dispatchEvent(new Event('change', { bubbles: true })); + fixture.detectChanges(); + expect(fake.commands.seekTo).toHaveBeenCalledWith(400); + + slider.dispatchEvent(new FocusEvent('blur')); + fixture.detectChanges(); expect( - query( + query('[data-test-id="player-controls-timeline-label"]') + ).toBeNull(); + }); + + it('moves the label by its measured width to stay inside the player', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 400, + positionSeconds: 0, + }); + fixture.detectChanges(); + // The player spans 0–800px; the bar 70–730px. + const host = fixture.nativeElement as HTMLElement; + host.getBoundingClientRect = () => + ({ left: 0, right: 800, width: 800 }) as DOMRect; + const bar = query('.player-controls__timeline-bar') as HTMLElement; + bar.getBoundingClientRect = () => + ({ left: 70, width: 660 }) as DOMRect; + const offsetWidth = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetWidth' + ); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get(this: HTMLElement) { + return this.matches('.player-controls__timeline-label') + ? 390 + : 0; + }, + }); + + try { + bar.dispatchEvent( + new MouseEvent('pointermove', { clientX: 70, bubbles: true }) + ); + fixture.detectChanges(); + const label = query( '[data-test-id="player-controls-timeline-label"]' - )?.textContent?.trim() - ).toBe('5:00'); + ) as HTMLElement; + // 8px in from the player's left edge, in bar coordinates. + expect(label.style.left).toBe('-62px'); + expect(label.style.transform).toBe('none'); + + bar.dispatchEvent( + new MouseEvent('pointermove', { clientX: 730, bubbles: true }) + ); + fixture.detectChanges(); + // Its right edge 8px in from the player's right edge. + expect(label.style.left).toBe(`${800 - 8 - 390 - 70}px`); + } finally { + if (offsetWidth) { + Object.defineProperty( + HTMLElement.prototype, + 'offsetWidth', + offsetWidth + ); + } + } }); it('does not hover-label a non-seekable timeline', () => { 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 fcfc38f57..d081937a0 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 @@ -139,7 +139,7 @@ > <app-player-timeline [timeline]="timeline" - [hover]="timelineHover" + [label]="timelineLabel" [seekable]="capabilities().seek" [canSeek]="state().canSeek" [isLive]="state().isLive" 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 bc85bc1f6..07a4b47be 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 @@ -30,6 +30,13 @@ --pc-text-dim: rgba(255, 255, 255, 0.82); --pc-scrim-ink: 4, 7, 11; --pc-track: rgba(255, 255, 255, 0.18); + // The seek bar's unplayed track: `--pc-track` over the scrim ink, made + // opaque so its contrast no longer depends on the frame. The white + // separators between programmes or chapters read 12.5:1 on it and 3.2:1 + // on the played fill, which reads 4.0:1 on it. Translucent, over a + // bright frame, a separator fell to 2.5:1 and the fill to 1.1:1. + --pc-timeline-track: #313437; + --pc-timeline-separator: #ffffff; --pc-hover: rgba(255, 255, 255, 0.1); --pc-glass-bg: rgba(12, 16, 23, 0.72); // The settings panel carries small secondary text over whatever frame is 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 a11cb97c1..52ad9dfce 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 @@ -1,6 +1,7 @@ import { WritableSignal, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { buildCatchupTimelineSegments } from './catchup-timeline-segments'; import { DEFAULT_PLAYER_CAPABILITIES, createEmptyControlsState, @@ -211,3 +212,119 @@ describe('PlayerControlsComponent timeline scrubbing', () => { expect(currentTimeText()).toBe('0:30'); }); }); + +describe('PlayerControlsComponent timeline value text', () => { + let fixture: ComponentFixture<PlayerControlsComponent>; + let fake: ReturnType<typeof createFakeController>; + let translate: TranslateService; + + const slider = () => + fixture.nativeElement.querySelector( + '.player-controls__slider' + ) as HTMLInputElement; + + const valueText = () => slider().getAttribute('aria-valuetext'); + + /** A keyboard step: the range input moves, then commits. */ + const step = (value: number) => { + const element = slider(); + element.value = String(value); + element.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + }; + + beforeEach(async () => { + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { TIMELINE_SEGMENT_POSITION: '{{title}} · {{time}}' }, + }, + }); + translate.use('en'); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + fixture.componentRef.setInput('controller', fake.controller); + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, seek: true }); + fake.state.set({ + ...createEmptyControlsState(), + canSeek: true, + durationSeconds: 3600, + positionSeconds: 300, + }); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('reads the plain time without segments', () => { + fixture.detectChanges(); + + expect(valueText()).toBe('5:00'); + }); + + it('names the catch-up programme at the position and at the keyboard target', () => { + const programme = (title: string, start: number, stop: number) => ({ + title, + start: '', + stop: '', + startTimestamp: start, + stopTimestamp: stop, + }); + const active = programme('Morning Report', 50_000, 51_800); + fixture.componentRef.setInput( + 'timelineSegments', + buildCatchupTimelineSegments( + [active, programme('Weather', 51_800, 53_600)], + active, + 53_600 + ) + ); + fixture.detectChanges(); + + expect(valueText()).toBe('Morning Report · 5:00'); + + step(1801); + expect(valueText()).toBe('Weather · 30:01'); + }); + + it('names file chapters and reads plain time in an untitled one', () => { + // Chapters as Embedded MPV reports them: each runs to the next. + fixture.componentRef.setInput('timelineSegments', [ + { startSeconds: 0, endSeconds: 240, title: 'Opening' }, + { startSeconds: 240, endSeconds: 3300, title: null }, + { startSeconds: 3300, endSeconds: 3600, title: 'Credits' }, + ]); + fixture.detectChanges(); + + expect(valueText()).toBe('5:00'); + + step(120); + expect(valueText()).toBe('Opening · 2:00'); + + step(3600); + expect(valueText()).toBe('Credits · 1:00:00'); + }); + + it('follows the locale template', () => { + translate.setTranslation('de', { + EMBEDDED_MPV: { + PLAYER: { TIMELINE_SEGMENT_POSITION: '{{time}} – {{title}}' }, + }, + }); + fixture.componentRef.setInput('timelineSegments', [ + { startSeconds: 0, endSeconds: 600, title: 'Kapitel 1' }, + ]); + fixture.detectChanges(); + + translate.use('de'); + fixture.detectChanges(); + + expect(valueText()).toBe('5:00 – Kapitel 1'); + }); +}); 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 d5552c155..299296235 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 @@ -25,7 +25,7 @@ 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 { ControlsTimelineLabel } from './controls-timeline-label'; import { ControlsUpNext } from './controls-up-next'; import { ControlsVisibility } from './controls-visibility'; import { createControlsViewModel } from './controls-view-model'; @@ -165,12 +165,14 @@ export class PlayerControlsComponent implements OnDestroy { private readonly controllerVolume = computed(() => this.state().volume); readonly timeline = new ControlsTimeline(this.state, this.timelineSegments); readonly scrubPosition = this.timeline.scrubPosition; - readonly timelineHover = new ControlsTimelineHover({ + readonly timelineLabel = new ControlsTimelineLabel({ duration: this.timeline.duration, interactive: computed( () => this.capabilities().seek && this.state().canSeek ), segments: this.timeline.segments, + value: this.timeline.value, + scrubbing: computed(() => this.scrubPosition() !== null), }); /** `−7:03` while a finite duration is known; the dock prefers it to the total. */ readonly remainingTimeText = computed(() => diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts index ac40ad085..148eacdd7 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts @@ -1,5 +1,6 @@ import { readdirSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { TIMELINE_SEGMENT_GAP_PX } from './controls-timeline-segments'; // The overlay sits on video in both app themes, so its colours come from the // fixed `--pc-*` palette. A theme token read here stays invisible only while @@ -32,6 +33,7 @@ const TIMELINE_STYLES = STYLE_SOURCES.get('player-timeline.component.scss') ?? ''; const SETTINGS_STYLES = STYLE_SOURCES.get('player-settings-panel.component.scss') ?? ''; +const PALETTE_STYLES = STYLE_SOURCES.get('_player-palette.scss') ?? ''; type Rgb = [number, number, number]; @@ -211,6 +213,59 @@ describe('player controls overlay palette', () => { expect(focused).toMatch(/outline:\s*2px solid var\(--pc-text[,)]/); }); + it('separates timeline segments at 3:1 over any frame', () => { + const track = paletteColor('--pc-timeline-track'); + const separator = paletteColor('--pc-timeline-separator'); + const accent = PALETTE_STYLES.match( + /\$accent-blue:\s*#([0-9a-f]{6});/i + )?.[1]; + const fill = [0, 2, 4].map((offset) => + Number.parseInt(accent?.slice(offset, offset + 2) ?? '', 16) + ) as Rgb; + + // Opaque, so no frame behind the bar can change these ratios. + expect(track.alpha).toBe(1); + expect(separator.alpha).toBe(1); + expect( + ruleBody(TIMELINE_STYLES, '.player-controls__timeline-segment') + ).toMatch(/background:\s*var\(--pc-timeline-track\);/); + expect( + ruleBody(TIMELINE_STYLES, '.player-controls__timeline-fill') + ).toMatch(/background:\s*var\(--pc-progress\);/); + const separatorRule = ruleBody( + TIMELINE_STYLES, + '.player-controls__timeline-separator' + ); + expect(separatorRule).toMatch( + /background:\s*var\(--pc-timeline-separator\);/ + ); + // The separator fills exactly the gap the segment widths leave. + expect(separatorRule).toMatch( + new RegExp(`width:\\s*${TIMELINE_SEGMENT_GAP_PX}px;`) + ); + + // A boundary in the unplayed and in the played stretch. + expect(contrastRatio(separator.rgb, track.rgb)).toBeGreaterThanOrEqual( + 3 + ); + expect(contrastRatio(separator.rgb, fill)).toBeGreaterThanOrEqual(3); + // And the played fill still reads against the track. + expect(contrastRatio(fill, track.rgb)).toBeGreaterThanOrEqual(3); + }); + + it('sets the timeline label on the dense glass', () => { + const white: Rgb = [255, 255, 255]; + expect( + ruleBody(TIMELINE_STYLES, '.player-controls__timeline-label') + ).toMatch(/background:\s*var\(--pc-glass-bg-dense\);/); + expect( + contrastRatio( + paletteColor('--pc-text').rgb, + over(paletteColor('--pc-glass-bg-dense'), white) + ) + ).toBeGreaterThanOrEqual(4.5); + }); + it('keeps the palette reds readable on video', () => { const white: Rgb = [255, 255, 255]; const glass = paletteColor('--pc-glass-bg'); 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 index b28e252c4..34bd7135c 100644 --- a/libs/ui/playback/src/lib/player-controls/player-timeline.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.html @@ -1,12 +1,15 @@ +@let position = formatTime(timeline().value()); +@let positionTitle = timeline().segmentTitle(); <span class="player-controls__time player-controls__time--current">{{ - formatTime(timeline().value()) + position }}</span> @if (seekable()) { <div + #bar class="player-controls__timeline-bar" [class.player-controls__timeline-bar--disabled]="!canSeek()" - (pointermove)="hover().move($event)" - (pointerleave)="hover().clear()" + (pointermove)="label().move($event)" + (pointerleave)="label().clear()" > <div class="player-controls__timeline-track" aria-hidden="true"> @for (segment of timeline().segments(); track $index) { @@ -24,6 +27,12 @@ [style.width.%]="timeline().fillPercent(segment)" ></div> </div> + @if (segment.separatorLeft; as separatorLeft) { + <div + class="player-controls__timeline-separator" + [style.left]="separatorLeft" + ></div> + } } </div> @if (canSeek()) { @@ -33,19 +42,33 @@ aria-hidden="true" ></div> } - @if (hover().percent() !== null) { + @if (label().markerPercent() !== null) { <div class="player-controls__timeline-marker" - [style.left.%]="hover().percent()" + [style.left.%]="label().markerPercent()" aria-hidden="true" ></div> + } + @if (label().text(); as text) { <div + #labelElement class="player-controls__timeline-label" - [style.--hover-x.%]="hover().percent()" + [style.--anchor-x.%]="label().percent()" data-test-id="player-controls-timeline-label" aria-hidden="true" > - {{ hover().label() }} + @if (text.title) { + <span + class="player-controls__timeline-label-title" + data-test-id="player-controls-timeline-label-title" + >{{ text.title }}</span + > + } + <span + class="player-controls__timeline-label-time" + data-test-id="player-controls-timeline-label-time" + >{{ text.time }}</span + > </div> } <input @@ -59,7 +82,15 @@ [attr.aria-label]=" 'EMBEDDED_MPV.PLAYER.PLAYBACK_POSITION' | translate " - [attr.aria-valuetext]="formatTime(timeline().value())" + [attr.aria-valuetext]=" + positionTitle + ? ('EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION' + | translate: { title: positionTitle, time: position }) + : position + " + (focus)="label().focus($event)" + (blur)="label().blur()" + (keydown)="label().keydown($event)" (input)="scrubInput.emit($event)" (change)="scrubCommit.emit($event)" /> @@ -76,13 +107,19 @@ } @else if (isLive()) { <span class="player-controls__live-badge" + role="img" [attr.aria-label]="'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate" > <span class="player-controls__live-dot" aria-hidden="true"></span> - LIVE + {{ 'EMBEDDED_MPV.PLAYER.LIVE_BADGE' | translate }} </span> } @else { - <span class="player-controls__time player-controls__time--end">--:--</span> + <span + class="player-controls__time player-controls__time--end" + role="img" + [attr.aria-label]="'EMBEDDED_MPV.PLAYER.DURATION_UNKNOWN' | translate" + >{{ unknownTime }}</span + > } @if (recordingStatus(); as status) { <div 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 index f659b3b76..cc87af1b7 100644 --- a/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss @@ -49,16 +49,36 @@ } // 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. +// share, minus the 2px gap after every segment but the last), so a drawn +// boundary is where the linear seek input and the label change segment. A +// segment shorter than the gap simply collapses rather than shifting its +// neighbours. Only the bar's two ends are rounded; inside it, a white +// separator fills each gap, so boundaries hold 3:1 over any frame. .player-controls__timeline-segment { position: absolute; top: 0; bottom: 0; overflow: hidden; - border-radius: 3px; - background: var(--pc-track); + background: var(--pc-timeline-track); +} + +.player-controls__timeline-segment:first-child { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.player-controls__timeline-segment:last-child { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +// Width = `TIMELINE_SEGMENT_GAP_PX`. +.player-controls__timeline-separator { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: var(--pc-timeline-separator); } .player-controls__timeline-fill { @@ -98,16 +118,26 @@ pointer-events: none; } +// Above the bar at its anchor: the pointer, or the slider value while the +// keyboard or a drag moves it. Centred on the anchor here; once laid out, +// the component moves it by its measured width to stay inside the player +// (`clampTimelineLabelLeft`), so its width must not depend on `left`. A +// long title ellipsizes; the time never truncates. .player-controls__timeline-label { - --hover-x: 0%; + --anchor-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)); + left: clamp(28px, var(--anchor-x), calc(100% - 28px)); transform: translateX(-50%); + display: flex; + align-items: baseline; + box-sizing: border-box; + width: max-content; + // The player's width less `TIMELINE_LABEL_EDGE_PX` on each side. + max-width: calc(100cqw - 16px); padding: 5px 8px; color: var(--pc-text); - background: rgba(12, 16, 23, 0.85); + background: var(--pc-glass-bg-dense); border-radius: 6px; font-size: 11.5px; font-weight: 600; @@ -116,6 +146,23 @@ pointer-events: none; } +.player-controls__timeline-label-title { + min-width: 0; + max-width: min(320px, 60cqw); + overflow: hidden; + text-overflow: ellipsis; +} + +.player-controls__timeline-label-time { + flex: none; +} + +.player-controls__timeline-label-title + + .player-controls__timeline-label-time::before { + content: '\00b7'; + margin: 0 0.4em; +} + .player-controls__recording-status { display: flex; align-items: center; 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 index 383014364..5638f0f52 100644 --- a/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts @@ -1,18 +1,25 @@ import { ChangeDetectionStrategy, Component, + ElementRef, + afterRenderEffect, + inject, input, output, + viewChild, } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; -import { formatTime } from './controls-format.utils'; +import { UNKNOWN_TIME_TEXT, formatTime } from './controls-format.utils'; import type { ControlsTimeline } from './controls-timeline'; -import type { ControlsTimelineHover } from './controls-timeline-hover'; +import { + type ControlsTimelineLabel, + clampTimelineLabelLeft, +} from './controls-timeline-label'; /** * The dock's timeline row: current time, the drawn segment track with its - * knob and hover label, the remaining time (or LIVE / `--:--`), and the + * knob and 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. @@ -26,8 +33,9 @@ import type { ControlsTimelineHover } from './controls-timeline-hover'; host: { class: 'player-controls__timeline' }, }) export class PlayerTimelineComponent { + private readonly host = inject(ElementRef<HTMLElement>).nativeElement; readonly timeline = input.required<ControlsTimeline>(); - readonly hover = input.required<ControlsTimelineHover>(); + readonly label = input.required<ControlsTimelineLabel>(); /** The engine can seek at all; without it the track is not drawn. */ readonly seekable = input(false); /** Seeking is possible right now. */ @@ -41,4 +49,50 @@ export class PlayerTimelineComponent { readonly scrubCommit = output<Event>(); readonly formatTime = formatTime; + readonly unknownTime = UNKNOWN_TIME_TEXT; + + private readonly bar = viewChild<ElementRef<HTMLElement>>('bar'); + private readonly labelElement = + viewChild<ElementRef<HTMLElement>>('labelElement'); + + constructor() { + // The stylesheet centres the label on its anchor; once laid out, it + // is moved by its measured width to stay inside the player, which a + // long programme or chapter title would otherwise overflow. + afterRenderEffect({ + earlyRead: () => { + const percent = this.label().percent(); + // A new title or time changes the width to clamp. + this.label().text(); + const label = this.labelElement()?.nativeElement; + const bar = this.bar()?.nativeElement; + if (percent === null || !label || !bar) { + return null; + } + const barRect = bar.getBoundingClientRect(); + if (!(barRect.width > 0)) { + return null; + } + const player = ( + this.host.closest('.player-controls-host') ?? bar + ).getBoundingClientRect(); + return { + label, + left: clampTimelineLabelLeft( + percent, + label.offsetWidth, + barRect, + player + ), + }; + }, + write: (placement) => { + const value = placement(); + if (value) { + value.label.style.left = `${value.left}px`; + value.label.style.transform = 'none'; + } + }, + }); + } } diff --git a/tools/i18n/identical-en-baseline.json b/tools/i18n/identical-en-baseline.json index 224a25605..8f58fec35 100644 --- a/tools/i18n/identical-en-baseline.json +++ b/tools/i18n/identical-en-baseline.json @@ -4,6 +4,7 @@ "CHANNELS.DETAILS_DIALOG.HTTP_REFERRER": "Referrer", "CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT": "User-Agent", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", "HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes", "HOME.PLAYLIST_TYPES.STALKER": "Stalker", @@ -110,6 +111,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec", "CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", "HOME.PLAYLISTS.FEATURE_STALKER": "Stalker Portal", "HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes", @@ -217,6 +219,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec", "CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", "HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes", @@ -323,11 +326,13 @@ "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", "DOWNLOADS.TITLE": "Downloads", "EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}", + "EMBEDDED_MPV.PLAYER.LIVE_BADGE": "LIVE", "EMBEDDED_MPV.PLAYER.PAUSE": "Pause", "EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio", "EMBEDDED_MPV.PLAYER.STATS_CONTAINER": "Container", "EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video", "EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_CYAN": "Cyan", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "EPG.GUIDE.ZOOM": "Zoom", "EPG.LIVE_STREAM": "Live stream", "EPG.PROGRAM_DIALOG.EPISODE": "Episode", @@ -445,6 +450,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID", "CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL", "HOME.FILE_UPLOAD.OR": "or", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", @@ -560,6 +566,7 @@ "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", "EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}", "EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "EPG.GUIDE.ZOOM": "Zoom", "EPG.TIMELINE.MINUTES_SHORT": "min", "EPG.TIMELINE.ZOOM": "Zoom", @@ -690,6 +697,7 @@ "EMBEDDED_MPV.PLAYER.QUALITY_AUTO": "Auto", "EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio", "EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_CYAN": "Cyan", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "EMBEDDED_MPV.PLAYER.VOLUME": "Volume", "EPG.GUIDE.KEYS.PROGRAMME": "programme", "EPG.GUIDE.ZOOM": "Zoom", @@ -822,6 +830,7 @@ "DOWNLOADS.RESUME": "Resume", "DOWNLOADS.STATUS.PAUSED": "Paused", "EMBEDDED_MPV.PLAYER.BACK": "Back", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "EPG_MAPPING_DIALOG.CLOSE": "Close", "EPG_MAPPING_DIALOG.CURRENT_MAPPING": "Current mapping", "EPG_MAPPING_DIALOG.NO_MAPPING": "No EPG mapping set", @@ -954,6 +963,7 @@ "EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio", "EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer", "EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "EMBEDDED_MPV.PLAYER.VOLUME": "Volume", "EMBEDDED_MPV.PLAYER.VOLUME_LABEL": "Volume {{percent}}%", "EPG.GUIDE.ZOOM": "Zoom", @@ -1080,6 +1090,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec", "CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", "HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes", @@ -1182,6 +1193,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec", "CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "EPG.DOWNLOAD_SUCCESS": "EPG was successfully downloaded.", "EPG.ERROR": "Ooops, EPG could not be loaded.", "EPG.LIVE_STREAM": "Live stream", @@ -1302,11 +1314,13 @@ "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", "DOWNLOADS.TITLE": "Downloads", "EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}", + "EMBEDDED_MPV.PLAYER.LIVE_BADGE": "LIVE", "EMBEDDED_MPV.PLAYER.LIVE_STREAM": "Live stream", "EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio", "EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer", "EMBEDDED_MPV.PLAYER.STATS_CONTAINER": "Container", "EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "EMBEDDED_MPV.PLAYER.VOLUME": "Volume", "EMBEDDED_MPV.PLAYER.VOLUME_LABEL": "Volume {{percent}}%", "EPG.GUIDE.KEYS.DETAILS": "details", @@ -1461,6 +1475,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", "EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "EPG.TIMELINE.MINUTES_SHORT": "min", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", "HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes", @@ -1573,6 +1588,7 @@ "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", "DOWNLOADS.TITLE": "Downloads", "EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "EMBEDDED_MPV.PLAYER.VOLUME": "Volume", "EMBEDDED_MPV.PLAYER.VOLUME_LABEL": "Volume {{percent}}%", "EPG.GUIDE.ZOOM": "Zoom", @@ -1694,6 +1710,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID", "CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", "HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes", @@ -1745,6 +1762,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", "EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", "HOME.PLAYLIST_TYPES.STALKER": "Stalker", @@ -1852,6 +1870,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec", "CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", "HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes", @@ -1953,6 +1972,7 @@ "CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID", "CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec", "DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}", + "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}", "HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL", "HOME.PLAYLISTS.CONTENT_EPG": "EPG", "HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",