From adb4889b0f1b6d7e5e63749af2ce84d27cf7addf Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:37:02 +0200 Subject: [PATCH] fix(player): keyboard focus, contrast and ARIA for controls and settings (#1769) * fix(player): keyboard focus, contrast and ARIA for controls and settings Dock and settings-panel icon buttons draw a 2px --pc-text ring on :focus-visible, and Material's theme-coloured focus layer is off, so keyboard focus shows on video in the light theme too. A focused selected subtitle swatch now differs from one that is only selected. Settings headings read --pc-text-secondary on denser glass (--pc-glass-bg-dense, 0.86): 4.5:1 or more over mid-grey and white frames. They wrap (overflow-wrap: anywhere, hyphens: auto), so long German and Russian headings stay inside the sheet's heading column. The settings panel is now radio groups only (SettingsRadioGroupDirective over a CDK FocusKeyManager): one Tab stop per group on the checked option, arrows, Home and End move focus without applying, and Space/Enter checks. The dialog and its groups are named by real h2/h3/h4 headings, the load-file action sits outside the subtitle radio group, the subtitle and speed chips carry their value in their name ("Subtitles: English"), and tune has aria-haspopup="dialog". Adds a web E2E for the keyboard path in both themes with an axe check on the open panel, and de/ru sheet heading wrapping; axe-core is a new dev dependency for it. Co-Authored-By: Claude Opus 5.5 * fix(player): arrows check settings radios; subtitle chip reads On Review follow-up: - Arrow keys, Home and End now check the settings radio they reach, as a native radio group does (the directive clicks it, so the template's handler applies the choice); an option the engine already reports as checked is not applied again. - With subtitles on but no track marked selected yet (the engine can report the switch before the track list), the subtitle chip reads and announces "On" (new SUBTITLES_ON key, 19 locales) instead of "Off". - The swatch row has 4px padding on every side, so the outer focus ring is not clipped at the scroll edge of the panel body. Co-Authored-By: Claude Opus 5.5 * fix(player): re-apply a settings radio while a switch is pending The arrow-key check skipped any option the engine still reported as checked. Arrowing from audio track A to B and back to A before the engine confirmed B therefore sent no command for A, and playback ended on B with focus on A. An arrow move always lands on an option other than the last one applied, so it now applies unconditionally. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changes/player-controls-keyboard-access.md | 9 + .../src/player-controls-keyboard.e2e.ts | 410 ++++++++++++++++++ apps/web-e2e/src/player-settings-panel.e2e.ts | 105 +---- .../src/player-settings-panel.fixture.ts | 114 +++++ apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/iptvnator-ui-guidelines.md | 4 +- docs/architecture/player-controls-contract.md | 49 ++- .../player-controls.component.html | 25 +- .../player-controls.component.scss | 13 + ...-controls.component.settings-panel.spec.ts | 4 +- ...ntrols.component.subtitle-settings.spec.ts | 6 +- .../player-controls.palette.spec.ts | 81 ++++ .../player-settings-panel.a11y.spec.ts | 355 +++++++++++++++ .../player-settings-panel.component.html | 235 +++++----- .../player-settings-panel.component.scss | 82 +++- .../player-settings-panel.component.ts | 24 +- .../settings-radio-group.directive.ts | 143 ++++++ package.json | 1 + pnpm-lock.yaml | 9 + 37 files changed, 1450 insertions(+), 257 deletions(-) create mode 100644 .changes/player-controls-keyboard-access.md create mode 100644 apps/web-e2e/src/player-controls-keyboard.e2e.ts create mode 100644 apps/web-e2e/src/player-settings-panel.fixture.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-settings-panel.a11y.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/settings-radio-group.directive.ts diff --git a/.changes/player-controls-keyboard-access.md b/.changes/player-controls-keyboard-access.md new file mode 100644 index 000000000..745fe8489 --- /dev/null +++ b/.changes/player-controls-keyboard-access.md @@ -0,0 +1,9 @@ +--- +type: fix +area: player +--- + +Player controls now show a clear focus ring in the light theme too. The +settings panel works with arrow keys and screen readers, and the subtitle and +speed buttons announce their current value. Settings headings are easier to +read over bright video and wrap in long translations instead of overflowing. diff --git a/apps/web-e2e/src/player-controls-keyboard.e2e.ts b/apps/web-e2e/src/player-controls-keyboard.e2e.ts new file mode 100644 index 000000000..15a441f36 --- /dev/null +++ b/apps/web-e2e/src/player-controls-keyboard.e2e.ts @@ -0,0 +1,410 @@ +import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { pressTab } from './e2e-helpers'; +import { expect, test } from './fixtures'; +import { + importPlaylist, + selectPlayer, + serveClip, + startClip, +} from './player-settings-panel.fixture'; + +/** + * Keyboard and assistive-technology access to the shared controls in both + * app themes: every dock control and every settings choice shows a focus + * ring in the overlay's own colour, each settings radio group is one Tab + * stop with arrow-key navigation, the open panel passes axe, its headings + * stay readable over bright frames, and long German and Russian headings + * wrap inside the compact sheet. ArtPlayer is used because it offers the + * fullest panel (subtitle file, size and colour, speed). + */ + +test.use({ serviceWorkers: 'block' }); + +/** `--pc-text`, the overlay's text colour, as computed styles report it. */ +const RING_COLOR = 'rgb(231, 236, 243)'; +const AXE_SOURCE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); + +interface FocusStop { + label: string; + role: string | null; + group: string | null; + checked: string | null; + iconButton: boolean; + outline: string; + outlineColor: string; +} + +/** Describes the focused element and the ring it draws. */ +function focusStop(page: Page): Promise { + return page.evaluate(() => { + const element = document.activeElement; + if (!(element instanceof HTMLElement)) return null; + const style = getComputedStyle(element); + const group = element.closest('[role="radiogroup"]'); + return { + label: + element.getAttribute('aria-label') ?? + element.textContent?.trim() ?? + '', + role: element.getAttribute('role'), + group: group?.getAttribute('aria-labelledby') ?? null, + checked: element.getAttribute('aria-checked'), + iconButton: element.matches('button[mat-icon-button]'), + outline: `${style.outlineStyle} ${style.outlineWidth}`, + outlineColor: style.outlineColor, + }; + }); +} + +function focusIsInside(page: Page, selector: string): Promise { + return page.evaluate( + (within) => !!document.activeElement?.closest(within), + selector + ); +} + +/** + * Presses Tab (or Shift+Tab) until focus lies inside `target`, or leaves + * `within` when that is given, and returns every stop on the way — the + * last one included. + */ +async function tabUntil( + page: Page, + browserName: string, + options: { + target?: string; + within?: string; + direction?: 'forward' | 'backward'; + max?: number; + } +): Promise { + const stops: FocusStop[] = []; + for (let step = 0; step < (options.max ?? 20); step++) { + await pressTab(page, browserName, options.direction); + if (options.within && !(await focusIsInside(page, options.within))) { + return stops; + } + const stop = await focusStop(page); + if (stop) stops.push(stop); + if (options.target && (await focusIsInside(page, options.target))) { + return stops; + } + } + return stops; +} + +async function applyTheme(page: Page, theme: 'light' | 'dark') { + await page.evaluate((dark) => { + document.body.classList.toggle('dark-theme', dark); + }, theme === 'dark'); +} + +async function expectNoAxeViolations(panel: Locator) { + await panel.page().evaluate(AXE_SOURCE); + const violations = await panel.evaluate(async (element) => { + const axe = (window as unknown as { axe: typeof import('axe-core') }) + .axe; + const results = await axe.run(element, { resultTypes: ['violations'] }); + return results.violations.map((violation) => ({ + id: violation.id, + nodes: violation.nodes.map((node) => node.target.join(' ')), + })); + }); + expect(violations).toEqual([]); +} + +/** + * Heading contrast on the panel glass composited over a mid-grey and a white + * frame — the video behind the panel can be anything. + */ +function headingContrast(panel: Locator) { + return panel.evaluate((host) => { + type Color = [number, number, number, number]; + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + const parse = (css: string): Color => { + ctx.clearRect(0, 0, 1, 1); + ctx.fillStyle = css; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + return [r, g, b, a / 255]; + }; + // `front` over an opaque `back`: the result is opaque too. + const over = (front: Color, back: Color): Color => [ + front[0] * front[3] + back[0] * (1 - front[3]), + front[1] * front[3] + back[1] * (1 - front[3]), + front[2] * front[3] + back[2] * (1 - front[3]), + 1, + ]; + const luminance = (color: Color) => { + const [r, g, b] = color.slice(0, 3).map((value) => { + const s = value / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return r * 0.2126 + g * 0.7152 + b * 0.0722; + }; + const ratio = (a: Color, b: Color) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); + }; + const glass = parse(getComputedStyle(host).backgroundColor); + return Array.from(host.querySelectorAll('h3, h4'), (heading) => { + const text = parse(getComputedStyle(heading).color); + const on = (frame: Color) => { + const surface = over(glass, frame); + return ratio(over(text, surface), surface); + }; + return { + heading: heading.textContent?.trim(), + midGrey: on([128, 128, 128, 1]), + white: on([255, 255, 255, 1]), + }; + }); + }); +} + +test('@web @playback keyboard reaches the dock and the settings radios with a visible ring in both themes', async ({ + page, + browserName, +}) => { + test.setTimeout(120_000); + // Chips and the side panel need a player of at least 960px. + await page.setViewportSize({ width: 1600, height: 1000 }); + await serveClip(page); + await selectPlayer(page, 'ArtPlayer'); + await importPlaylist(page); + const { view, video, controls } = await startClip(page); + // Park the pointer on the video away from the dock and the panel: a + // control that slides in under a resting pointer shows its hover + // tooltip, and a visible tooltip spends the next Escape on itself. + await view.hover({ position: { x: 24, y: 24 } }); + const tune = controls.locator( + '[data-test-id="player-controls-settings-button"]' + ); + const panel = controls.getByRole('dialog', { name: 'Settings' }); + + for (const theme of ['light', 'dark'] as const) { + await applyTheme(page, theme); + + // --- The dock, from the timeline to the last control. ------------- + await controls + .getByRole('slider', { name: 'Playback position' }) + .focus(); + const dockButtons = ( + await tabUntil(page, browserName, { within: 'app-player-controls' }) + ).filter((stop) => stop.iconButton); + // Every icon button wears the ring, whatever the app theme. + expect(dockButtons.map((stop) => stop.outline)).toEqual( + dockButtons.map(() => 'solid 2px') + ); + expect(dockButtons.map((stop) => stop.outlineColor)).toEqual( + dockButtons.map(() => RING_COLOR) + ); + expect(dockButtons.map((stop) => stop.label)).toEqual( + expect.arrayContaining([ + 'Mute', + 'Back 10 seconds', + 'Forward 10 seconds', + 'Settings', + 'Enter fullscreen', + ]) + ); + + // --- Open the panel from the keyboard: focus moves into it. ------- + await tabUntil(page, browserName, { + target: '[data-test-id="player-controls-settings-button"]', + direction: 'backward', + max: 10, + }); + await expect(tune).toBeFocused(); + await expect(tune).toHaveAttribute('aria-haspopup', 'dialog'); + await page.screenshot({ + path: test.info().outputPath(`${theme}-dock-focus.png`), + }); + await page.keyboard.press('Enter'); + await expect(panel).toBeVisible(); + await expect(panel).toBeFocused(); + + // Tab enters every radio group once, on its checked option, and + // every stop wears the ring. + const panelStops = await tabUntil(page, browserName, { + target: '[data-test-id="player-settings-speed"]', + }); + expect(panelStops.map((stop) => stop.outline)).toEqual( + panelStops.map(() => 'solid 2px') + ); + expect(panelStops.map((stop) => stop.outlineColor)).toEqual( + panelStops.map(() => RING_COLOR) + ); + const radioStops = panelStops.filter((stop) => stop.role === 'radio'); + // Subtitle size, subtitle colour and speed: one stop each, checked. + expect(radioStops.map((stop) => stop.checked)).toEqual([ + 'true', + 'true', + 'true', + ]); + expect(new Set(radioStops.map((stop) => stop.group)).size).toBe(3); + + // --- Arrows move focus and apply the option they reach. ---------- + const speedGroup = panel.locator( + '[data-test-id="player-settings-speed"] [role="radiogroup"]' + ); + const rate = () => + video.evaluate((el: HTMLVideoElement) => el.playbackRate); + await page.keyboard.press('ArrowRight'); + const moved = await focusStop(page); + expect(moved?.role).toBe('radio'); + await expect.poll(rate).toBe(Number.parseFloat(moved?.label ?? '')); + await expect( + speedGroup.locator('[role="radio"][aria-checked="true"]') + ).toHaveText(moved?.label ?? ''); + await page.keyboard.press('Home'); + expect((await focusStop(page))?.label).toBe('0.5×'); + await expect.poll(rate).toBe(0.5); + await expect( + speedGroup.locator('[role="radio"][aria-checked="true"]') + ).toHaveText('0.5×'); + // The ends wrap, as in a native radio group. + await page.keyboard.press('ArrowLeft'); + expect((await focusStop(page))?.label).toBe('2×'); + await expect.poll(rate).toBe(2); + await expect(speedGroup.locator('[tabindex="0"]')).toHaveText('2×'); + // Leaving and re-entering lands on the checked option — the one the + // engine reports, so wait for it to confirm the switch first. + await expect( + speedGroup.locator('[role="radio"][aria-checked="true"]') + ).toHaveText('2×'); + await pressTab(page, browserName, 'backward'); + await pressTab(page, browserName); + expect((await focusStop(page))?.label).toBe('2×'); + + // --- A focused selected swatch differs from a selected one. ------- + await tabUntil(page, browserName, { + target: '.player-settings__swatches', + direction: 'backward', + max: 5, + }); + const focusedSwatch = panel.locator('.player-settings__swatch:focus'); + await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true'); + await expect(focusedSwatch).toHaveCSS('outline-style', 'solid'); + // The arrow checks the next swatch, which takes focus and the ring. + await page.keyboard.press('ArrowRight'); + await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true'); + await expect(focusedSwatch).toHaveCSS('outline-style', 'solid'); + // Tab moves on to the speed group: the checked swatch keeps its + // selected border but loses the ring. + await pressTab(page, browserName); + expect( + await focusIsInside(page, '[data-test-id="player-settings-speed"]') + ).toBe(true); + const selectedOnly = panel.locator( + '.player-settings__swatch[aria-checked="true"]' + ); + await expect(selectedOnly).toHaveCount(1); + await expect(selectedOnly).not.toBeFocused(); + await expect(selectedOnly).toHaveCSS('outline-style', 'none'); + await expect(selectedOnly).toHaveCSS( + 'border-top-color', + 'rgb(255, 255, 255)' + ); + + // --- Headings, axe and closing. ---------------------------------- + const contrast = await headingContrast(panel); + expect(contrast.length).toBeGreaterThan(0); + expect( + contrast.filter( + (heading) => heading.midGrey < 4.5 || heading.white < 4.5 + ) + ).toEqual([]); + await expectNoAxeViolations(panel); + await page.screenshot({ + path: test.info().outputPath(`${theme}-panel-focus.png`), + }); + + // Focus is on a speed radio, which has no tooltip. Material spends + // an Escape on any tooltip still showing (the swatch's fades out), + // so wait for it to go before closing. + await expect(page.locator('.mat-mdc-tooltip')).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + await expect(tune).toBeFocused(); + } +}); + +for (const [locale, speedHeading] of [ + ['de', 'Wiedergabegeschwindigkeit'], + ['ru', 'Скорость воспроизведения'], +] as const) { + test(`@web @playback settings sheet headings wrap in the ${locale} locale`, async ({ + page, + }) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 900, height: 700 }); + await serveClip(page); + await selectPlayer(page, 'ArtPlayer'); + const playlistUrl = await importPlaylist(page); + + await page.goto('/workspace/settings/general'); + await page.locator('[data-test-id="select-language"]').click(); + await page.locator(`mat-option[data-test-id="${locale}"]`).click(); + const save = page.locator('[data-test-id="save-settings"]'); + await save.click(); + await expect(save).toBeHidden(); + + await page.goto(playlistUrl); + const { controls } = await startClip(page); + await controls + .locator('[data-test-id="player-controls-settings-button"]') + .click(); + const sheet = controls.locator( + '[data-test-id="player-controls-settings-panel"]' + ); + await expect(sheet).toHaveClass(/player-controls__settings--sheet/); + await expect( + sheet.locator('[data-test-id="player-settings-speed"] h3') + ).toHaveText(speedHeading); + + for (const theme of ['light', 'dark'] as const) { + await applyTheme(page, theme); + const headings = await sheet.evaluate((host) => + Array.from(host.querySelectorAll('h3, h4'), (heading) => { + const box = heading.getBoundingClientRect(); + const control = heading.nextElementSibling; + const style = getComputedStyle(heading); + return { + text: heading.textContent?.trim(), + overflow: heading.scrollWidth - heading.clientWidth, + // A group heading sits in the sheet's left column, + // beside its control. + intoControl: + heading.tagName === 'H3' && control + ? box.right - + control.getBoundingClientRect().left + : 0, + lines: Math.round( + (box.height - + Number.parseFloat(style.paddingTop) - + Number.parseFloat(style.paddingBottom)) / + Number.parseFloat(style.lineHeight) + ), + }; + }) + ); + for (const heading of headings) { + expect(heading.overflow, heading.text).toBeLessThanOrEqual(0); + expect(heading.intoControl, heading.text).toBeLessThanOrEqual( + 0 + ); + } + expect( + headings.find((heading) => heading.text === speedHeading)?.lines + ).toBeGreaterThan(1); + await expectNoAxeViolations(sheet); + } + await page.screenshot({ + path: test.info().outputPath(`${locale}-sheet.png`), + }); + }); +} diff --git a/apps/web-e2e/src/player-settings-panel.e2e.ts b/apps/web-e2e/src/player-settings-panel.e2e.ts index 0f399a397..dca258724 100644 --- a/apps/web-e2e/src/player-settings-panel.e2e.ts +++ b/apps/web-e2e/src/player-settings-panel.e2e.ts @@ -1,7 +1,10 @@ -import type { Page } from '@playwright/test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { expect, test } from './fixtures'; +import { + importPlaylist, + selectPlayer, + serveClip, + startClip, +} from './player-settings-panel.fixture'; /** * The shared controls' settings panel: on a wide player the speed chip opens @@ -11,100 +14,8 @@ import { expect, test } from './fixtures'; * remote media is involved. */ -const FIXTURE_HOST = 'https://player-settings-fixture.local'; -const PLAYLIST = [ - '#EXTM3U', - '#EXTINF:-1 group-title="Movies",Settings Clip', - `${FIXTURE_HOST}/clip.mp4`, -].join('\n'); - test.use({ serviceWorkers: 'block' }); -async function serveClip(page: Page): Promise { - const clip = readFileSync( - join(__dirname, 'fixtures/playback/episode.webm') - ); - await page.route(`${FIXTURE_HOST}/**`, async (route) => { - const range = /^bytes=(\d*)-(\d*)$/.exec( - route.request().headers()['range'] ?? '' - ); - const last = clip.length - 1; - const start = range?.[1] - ? Number(range[1]) - : range?.[2] - ? Math.max(0, clip.length - Number(range[2])) - : 0; - const end = - range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last; - await route.fulfill({ - status: range ? 206 : 200, - headers: { - 'content-type': 'video/webm', - 'accept-ranges': 'bytes', - 'content-length': String(end - start + 1), - ...(range - ? { - 'content-range': `bytes ${start}-${end}/${clip.length}`, - } - : {}), - }, - body: clip.subarray(start, end + 1), - }); - }); -} - -async function selectHtml5Player(page: Page): Promise { - await page.goto('/workspace/settings/playback'); - const select = page.locator('[data-test-id="select-video-player"]'); - await expect(select).toBeVisible(); - const previous = await select.innerText(); - await select.click(); - await page - .getByRole('option', { name: 'HTML5 video player', exact: true }) - .click(); - if (!previous.includes('HTML5 video player')) { - const saveButton = page.getByRole('button', { name: 'Save changes' }); - await saveButton.click(); - await expect(saveButton).toBeHidden(); - } -} - -async function importPlaylist(page: Page): Promise { - await page.goto('/'); - await page.getByRole('button', { name: 'Add playlist' }).click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); - await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST); - await Promise.all([ - page.waitForURL(/\/workspace\/playlists\/.+\/all$/), - dialog.getByRole('button', { name: 'Import', exact: true }).click(), - ]); - await expect(page.getByText('1 channels')).toBeVisible(); -} - -async function startClip(page: Page) { - await page - .locator('[data-test-id="channel-item"]') - .filter({ hasText: 'Settings Clip' }) - .click(); - const view = page.locator('app-web-player-view'); - const video = view.locator('video'); - await expect - .poll(() => - video.evaluate( - (el: HTMLVideoElement) => - Number.isFinite(el.duration) && el.duration > 5 - ) - ) - .toBe(true); - // Reveal the dock and keep it revealed for the assertions below. - await view.hover(); - const controls = view.locator('app-player-controls'); - await controls.getByRole('button', { name: 'Pause', exact: true }).click(); - return { view, video, controls }; -} - test('@web @playback settings panel opens from the speed chip and applies in place', async ({ page, }) => { @@ -112,7 +23,7 @@ test('@web @playback settings panel opens from the speed chip and applies in pla // Chips and the side panel need a player of at least 960px. await page.setViewportSize({ width: 1600, height: 1000 }); await serveClip(page); - await selectHtml5Player(page); + await selectPlayer(page); await importPlaylist(page); const { video, controls } = await startClip(page); @@ -164,7 +75,7 @@ test('@web @playback compact player folds the chips into a tune button with a bo test.setTimeout(90_000); await page.setViewportSize({ width: 900, height: 700 }); await serveClip(page); - await selectHtml5Player(page); + await selectPlayer(page); await importPlaylist(page); const { view, controls } = await startClip(page); diff --git a/apps/web-e2e/src/player-settings-panel.fixture.ts b/apps/web-e2e/src/player-settings-panel.fixture.ts new file mode 100644 index 000000000..e2879718e --- /dev/null +++ b/apps/web-e2e/src/player-settings-panel.fixture.ts @@ -0,0 +1,114 @@ +import type { Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect } from './fixtures'; + +/** + * A one-channel M3U playlist whose only stream is the local test clip, served + * through route interception: the shared-controls specs play it in a web + * engine without any remote media. + */ +const FIXTURE_HOST = 'https://player-settings-fixture.local'; +export const CLIP_TITLE = 'Settings Clip'; +const PLAYLIST = [ + '#EXTM3U', + `#EXTINF:-1 group-title="Movies",${CLIP_TITLE}`, + `${FIXTURE_HOST}/clip.mp4`, +].join('\n'); + +export async function serveClip(page: Page): Promise { + const clip = readFileSync( + join(__dirname, 'fixtures/playback/episode.webm') + ); + await page.route(`${FIXTURE_HOST}/**`, async (route) => { + const range = /^bytes=(\d*)-(\d*)$/.exec( + route.request().headers()['range'] ?? '' + ); + const last = clip.length - 1; + const start = range?.[1] + ? Number(range[1]) + : range?.[2] + ? Math.max(0, clip.length - Number(range[2])) + : 0; + const end = + range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last; + await route.fulfill({ + status: range ? 206 : 200, + headers: { + 'content-type': 'video/webm', + 'accept-ranges': 'bytes', + 'content-length': String(end - start + 1), + ...(range + ? { + 'content-range': `bytes ${start}-${end}/${clip.length}`, + } + : {}), + }, + body: clip.subarray(start, end + 1), + }); + }); +} + +export async function selectPlayer( + page: Page, + player = 'HTML5 video player' +): Promise { + await page.goto('/workspace/settings/playback'); + const select = page.locator('[data-test-id="select-video-player"]'); + await expect(select).toBeVisible(); + const previous = await select.innerText(); + await select.click(); + await page.getByRole('option', { name: player, exact: true }).click(); + if (!previous.includes(player)) { + const saveButton = page.getByRole('button', { name: 'Save changes' }); + await saveButton.click(); + await expect(saveButton).toBeHidden(); + } +} + +/** Imports the playlist and returns the URL of its channel list. */ +export async function importPlaylist(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); + await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST); + await Promise.all([ + page.waitForURL(/\/workspace\/playlists\/.+\/all$/), + dialog.getByRole('button', { name: 'Import', exact: true }).click(), + ]); + await expect(page.getByText('1 channels')).toBeVisible(); + return page.url(); +} + +/** + * Starts the clip and pauses it with the dock revealed. The play button is + * found by test id, so this works in every app language. + */ +export async function startClip(page: Page) { + await page + .locator('[data-test-id="channel-item"]') + .filter({ hasText: CLIP_TITLE }) + .click(); + const view = page.locator('app-web-player-view'); + const video = view.locator('video'); + await expect + .poll(() => + video.evaluate( + (el: HTMLVideoElement) => + Number.isFinite(el.duration) && + el.duration > 5 && + !el.paused + ) + ) + .toBe(true); + // Reveal the dock and keep it revealed for the assertions below. + await view.hover(); + const controls = view.locator('app-player-controls'); + await controls.locator('[data-test-id="player-controls-play"]').click(); + await expect + .poll(() => video.evaluate((el: HTMLVideoElement) => el.paused)) + .toBe(true); + return { view, video, controls }; +} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 2f73604da..e4dff9e22 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "مسارات الصوت", "SUBTITLES": "الترجمات", "SUBTITLES_OFF": "إيقاف", + "SUBTITLES_ON": "تشغيل", "QUALITY": "الجودة", "QUALITY_AUTO": "تلقائي", "TRACK_DEFAULT": "افتراضي", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}", "PLAYBACK_SPEED": "سرعة التشغيل", "SPEED_TOOLTIP": "السرعة: {{speed}}", + "SUBTITLES_TOOLTIP": "الترجمات: {{subtitles}}", "ASPECT_RATIO": "نسبة العرض إلى الارتفاع", "ASPECT_TOOLTIP": "النسبة: {{aspect}}", "ASPECT_DEFAULT": "افتراضي", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index e23c291bc..56f24be99 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "مسارات الصوت", "SUBTITLES": "الترجمة", "SUBTITLES_OFF": "معطلة", + "SUBTITLES_ON": "مفعلة", "QUALITY": "الجودة", "QUALITY_AUTO": "تلقائي", "TRACK_DEFAULT": "افتراضي", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}", "PLAYBACK_SPEED": "سرعة التشغيل", "SPEED_TOOLTIP": "السرعة: {{speed}}", + "SUBTITLES_TOOLTIP": "الترجمة: {{subtitles}}", "ASPECT_RATIO": "نسبة الأبعاد", "ASPECT_TOOLTIP": "الأبعاد: {{aspect}}", "ASPECT_DEFAULT": "افتراضي", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 91f53d7b0..1a5583ef1 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Аўдыядарожкі", "SUBTITLES": "Субтытры", "SUBTITLES_OFF": "Выкл.", + "SUBTITLES_ON": "Укл.", "QUALITY": "Якасць", "QUALITY_AUTO": "Аўта", "TRACK_DEFAULT": "Па змаўчанні", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}", "PLAYBACK_SPEED": "Хуткасць прайгравання", "SPEED_TOOLTIP": "Хуткасць: {{speed}}", + "SUBTITLES_TOOLTIP": "Субтытры: {{subtitles}}", "ASPECT_RATIO": "Суадносіны бакоў", "ASPECT_TOOLTIP": "Суадносіны: {{aspect}}", "ASPECT_DEFAULT": "Па змаўчанні", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 9f05ac68c..9c7170c7e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Tonspuren", "SUBTITLES": "Untertitel", "SUBTITLES_OFF": "Aus", + "SUBTITLES_ON": "An", "QUALITY": "Qualität", "QUALITY_AUTO": "Automatisch", "TRACK_DEFAULT": "Standard", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}", "PLAYBACK_SPEED": "Wiedergabegeschwindigkeit", "SPEED_TOOLTIP": "Geschwindigkeit: {{speed}}", + "SUBTITLES_TOOLTIP": "Untertitel: {{subtitles}}", "ASPECT_RATIO": "Seitenverhältnis", "ASPECT_TOOLTIP": "Seitenverhältnis: {{aspect}}", "ASPECT_DEFAULT": "Standard", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index f40ec13cf..5a7d89dc5 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Κομμάτια ήχου", "SUBTITLES": "Υπότιτλοι", "SUBTITLES_OFF": "Ανενεργοί", + "SUBTITLES_ON": "Ενεργοί", "QUALITY": "Ποιότητα", "QUALITY_AUTO": "Αυτόματη", "TRACK_DEFAULT": "Προεπιλογή", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}", "PLAYBACK_SPEED": "Ταχύτητα αναπαραγωγής", "SPEED_TOOLTIP": "Ταχύτητα: {{speed}}", + "SUBTITLES_TOOLTIP": "Υπότιτλοι: {{subtitles}}", "ASPECT_RATIO": "Αναλογία εικόνας", "ASPECT_TOOLTIP": "Αναλογία: {{aspect}}", "ASPECT_DEFAULT": "Προεπιλογή", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 4e00a95c5..6544f35ee 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Audio tracks", "SUBTITLES": "Subtitles", "SUBTITLES_OFF": "Off", + "SUBTITLES_ON": "On", "QUALITY": "Quality", "QUALITY_AUTO": "Auto", "TRACK_DEFAULT": "Default", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}", "PLAYBACK_SPEED": "Playback speed", "SPEED_TOOLTIP": "Speed: {{speed}}", + "SUBTITLES_TOOLTIP": "Subtitles: {{subtitles}}", "ASPECT_RATIO": "Aspect ratio", "ASPECT_TOOLTIP": "Aspect: {{aspect}}", "ASPECT_DEFAULT": "Default", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 3fe7f484d..4c340b517 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Pistas de audio", "SUBTITLES": "Subtítulos", "SUBTITLES_OFF": "Desactivados", + "SUBTITLES_ON": "Activados", "QUALITY": "Calidad", "QUALITY_AUTO": "Automática", "TRACK_DEFAULT": "Predeterminada", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}", "PLAYBACK_SPEED": "Velocidad de reproducción", "SPEED_TOOLTIP": "Velocidad: {{speed}}", + "SUBTITLES_TOOLTIP": "Subtítulos: {{subtitles}}", "ASPECT_RATIO": "Relación de aspecto", "ASPECT_TOOLTIP": "Aspecto: {{aspect}}", "ASPECT_DEFAULT": "Predeterminada", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 1c432081e..812cb01dd 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Pistes audio", "SUBTITLES": "Sous-titres", "SUBTITLES_OFF": "Désactivés", + "SUBTITLES_ON": "Activés", "QUALITY": "Qualité", "QUALITY_AUTO": "Auto", "TRACK_DEFAULT": "Par défaut", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}", "PLAYBACK_SPEED": "Vitesse de lecture", "SPEED_TOOLTIP": "Vitesse : {{speed}}", + "SUBTITLES_TOOLTIP": "Sous-titres : {{subtitles}}", "ASPECT_RATIO": "Format d'image", "ASPECT_TOOLTIP": "Format : {{aspect}}", "ASPECT_DEFAULT": "Par défaut", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index f62ce758d..6027cebbf 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Hangsávok", "SUBTITLES": "Feliratok", "SUBTITLES_OFF": "Kikapcsolva", + "SUBTITLES_ON": "Bekapcsolva", "QUALITY": "Minőség", "QUALITY_AUTO": "Automatikus", "TRACK_DEFAULT": "Alapértelmezett", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "{{index}}. feliratsáv", "PLAYBACK_SPEED": "Lejátszási sebesség", "SPEED_TOOLTIP": "Sebesség: {{speed}}", + "SUBTITLES_TOOLTIP": "Feliratok: {{subtitles}}", "ASPECT_RATIO": "Képarány", "ASPECT_TOOLTIP": "Képarány: {{aspect}}", "ASPECT_DEFAULT": "Alapértelmezett", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 78aee97ab..a978c7560 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Tracce audio", "SUBTITLES": "Sottotitoli", "SUBTITLES_OFF": "Disattivati", + "SUBTITLES_ON": "Attivati", "QUALITY": "Qualità", "QUALITY_AUTO": "Automatica", "TRACK_DEFAULT": "Predefinita", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}", "PLAYBACK_SPEED": "Velocità di riproduzione", "SPEED_TOOLTIP": "Velocità: {{speed}}", + "SUBTITLES_TOOLTIP": "Sottotitoli: {{subtitles}}", "ASPECT_RATIO": "Proporzioni", "ASPECT_TOOLTIP": "Proporzioni: {{aspect}}", "ASPECT_DEFAULT": "Predefinite", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ff22ff6ad..e0ea48c88 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "音声トラック", "SUBTITLES": "字幕", "SUBTITLES_OFF": "オフ", + "SUBTITLES_ON": "オン", "QUALITY": "画質", "QUALITY_AUTO": "自動", "TRACK_DEFAULT": "デフォルト", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", "PLAYBACK_SPEED": "再生速度", "SPEED_TOOLTIP": "速度:{{speed}}", + "SUBTITLES_TOOLTIP": "字幕:{{subtitles}}", "ASPECT_RATIO": "アスペクト比", "ASPECT_TOOLTIP": "アスペクト比:{{aspect}}", "ASPECT_DEFAULT": "デフォルト", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 5065701fa..5c2adf2b7 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "오디오 트랙", "SUBTITLES": "자막", "SUBTITLES_OFF": "끄기", + "SUBTITLES_ON": "켜기", "QUALITY": "화질", "QUALITY_AUTO": "자동", "TRACK_DEFAULT": "기본", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "자막 {{index}}", "PLAYBACK_SPEED": "재생 속도", "SPEED_TOOLTIP": "속도: {{speed}}", + "SUBTITLES_TOOLTIP": "자막: {{subtitles}}", "ASPECT_RATIO": "화면 비율", "ASPECT_TOOLTIP": "비율: {{aspect}}", "ASPECT_DEFAULT": "기본", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 60f8a51c1..aebe8cc35 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Audiosporen", "SUBTITLES": "Ondertiteling", "SUBTITLES_OFF": "Uit", + "SUBTITLES_ON": "Aan", "QUALITY": "Kwaliteit", "QUALITY_AUTO": "Automatisch", "TRACK_DEFAULT": "Standaard", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}", "PLAYBACK_SPEED": "Afspeelsnelheid", "SPEED_TOOLTIP": "Snelheid: {{speed}}", + "SUBTITLES_TOOLTIP": "Ondertiteling: {{subtitles}}", "ASPECT_RATIO": "Beeldverhouding", "ASPECT_TOOLTIP": "Beeldverhouding: {{aspect}}", "ASPECT_DEFAULT": "Standaard", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 692a3df29..dfa56de3d 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Ścieżki audio", "SUBTITLES": "Napisy", "SUBTITLES_OFF": "Wyłączone", + "SUBTITLES_ON": "Włączone", "QUALITY": "Jakość", "QUALITY_AUTO": "Automatycznie", "TRACK_DEFAULT": "Domyślna", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}", "PLAYBACK_SPEED": "Prędkość odtwarzania", "SPEED_TOOLTIP": "Prędkość: {{speed}}", + "SUBTITLES_TOOLTIP": "Napisy: {{subtitles}}", "ASPECT_RATIO": "Proporcje obrazu", "ASPECT_TOOLTIP": "Proporcje: {{aspect}}", "ASPECT_DEFAULT": "Domyślne", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index aa718cf75..9c3fac933 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Faixas de áudio", "SUBTITLES": "Legendas", "SUBTITLES_OFF": "Desativadas", + "SUBTITLES_ON": "Ativadas", "QUALITY": "Qualidade", "QUALITY_AUTO": "Automática", "TRACK_DEFAULT": "Padrão", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}", "PLAYBACK_SPEED": "Velocidade de reprodução", "SPEED_TOOLTIP": "Velocidade: {{speed}}", + "SUBTITLES_TOOLTIP": "Legendas: {{subtitles}}", "ASPECT_RATIO": "Proporção da imagem", "ASPECT_TOOLTIP": "Proporção: {{aspect}}", "ASPECT_DEFAULT": "Padrão", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 0f6705621..1e6d37dc4 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Аудиодорожки", "SUBTITLES": "Субтитры", "SUBTITLES_OFF": "Выкл.", + "SUBTITLES_ON": "Вкл.", "QUALITY": "Качество", "QUALITY_AUTO": "Авто", "TRACK_DEFAULT": "По умолчанию", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}", "PLAYBACK_SPEED": "Скорость воспроизведения", "SPEED_TOOLTIP": "Скорость: {{speed}}", + "SUBTITLES_TOOLTIP": "Субтитры: {{subtitles}}", "ASPECT_RATIO": "Соотношение сторон", "ASPECT_TOOLTIP": "Соотношение: {{aspect}}", "ASPECT_DEFAULT": "По умолчанию", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index ab028b62c..863c33469 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Ses parçaları", "SUBTITLES": "Altyazılar", "SUBTITLES_OFF": "Kapalı", + "SUBTITLES_ON": "Açık", "QUALITY": "Kalite", "QUALITY_AUTO": "Otomatik", "TRACK_DEFAULT": "Varsayılan", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}", "PLAYBACK_SPEED": "Oynatma hızı", "SPEED_TOOLTIP": "Hız: {{speed}}", + "SUBTITLES_TOOLTIP": "Altyazılar: {{subtitles}}", "ASPECT_RATIO": "En boy oranı", "ASPECT_TOOLTIP": "En boy oranı: {{aspect}}", "ASPECT_DEFAULT": "Varsayılan", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 355bc1d2e..f860f0818 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "音轨", "SUBTITLES": "字幕", "SUBTITLES_OFF": "关闭", + "SUBTITLES_ON": "开启", "QUALITY": "画质", "QUALITY_AUTO": "自动", "TRACK_DEFAULT": "默认", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", "PLAYBACK_SPEED": "播放速度", "SPEED_TOOLTIP": "速度:{{speed}}", + "SUBTITLES_TOOLTIP": "字幕:{{subtitles}}", "ASPECT_RATIO": "宽高比", "ASPECT_TOOLTIP": "宽高比:{{aspect}}", "ASPECT_DEFAULT": "默认", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index c04e86a2f..ef606aab3 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "音軌", "SUBTITLES": "字幕", "SUBTITLES_OFF": "關閉", + "SUBTITLES_ON": "開啟", "QUALITY": "畫質", "QUALITY_AUTO": "自動", "TRACK_DEFAULT": "預設", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", "PLAYBACK_SPEED": "播放速度", "SPEED_TOOLTIP": "速度:{{speed}}", + "SUBTITLES_TOOLTIP": "字幕:{{subtitles}}", "ASPECT_RATIO": "長寬比", "ASPECT_TOOLTIP": "長寬比:{{aspect}}", "ASPECT_DEFAULT": "預設", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index c1b0b53a3..20ebe310c 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -91,7 +91,9 @@ themes and fullscreen; frame-copy and built-in shared controls keep their light-on-dark overlay palette — the fixed `--pc-*` token set of the shared dock (accent blue, cyan, violet, the `--pc-live` / `--pc-danger` reds and a light text ramp), never the app theme. The overlay styles in -`player-controls/` never read a `--mat-sys-*` token. +`player-controls/` never read a `--mat-sys-*` token, and their keyboard focus +is a 2px `--pc-text` outline rather than Material's theme-coloured focus layer +(`player-controls-keyboard.e2e.ts` checks it in both themes). EPG timeline, list, empty states and programme details use the library-local `libs/ui/epg/src/lib/_epg-theme.scss` palette, based on app surfaces, separators, diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index fc4e4c615..4bbdd20fd 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -297,7 +297,8 @@ It owns only transient presentation behavior: - `ControlsSettings` — the settings panel's groups, on/modified state and open/close transitions (`controls-settings-groups.ts` holds the pure group-availability rule); -- `app-player-settings-panel` — the panel / bottom sheet presentation; +- `app-player-settings-panel` — the panel / bottom sheet presentation, + whose radio groups use `settings-radio-group.directive.ts`; - `ControlsUpNext` and `app-player-up-next-card` — the "Up next" card's gate and presentation; and - `controls-view-model.ts` — derived display state. @@ -346,7 +347,11 @@ app's `--app-selection-color` is a different blue that would fight the video. measured below 3:1 against the lighter fills. - **Icon buttons** are 40px with a 12px radius (32px / 9px compact) through Material's `--mat-icon-button-*` tokens; their hover is a flat - `rgba(255,255,255,.1)` layer. + `rgba(255,255,255,.1)` layer. Keyboard focus draws a 2px `--pc-text` + outline (`:focus-visible`), and Material's focus state layer is switched + off: its colour comes from the app theme, and the light theme's dark layer + left no visible focus on video. The settings panel's icon buttons use the + same ring, inset by 2px because the panel body clips at its edge. ### Timeline segments @@ -458,7 +463,10 @@ the panel the moment the last group disappears. - **Roomy wide dock** (≥ 960px): two **value chips** precede `tune` — subtitles (`closed_caption` + the selected track's label, or "Off") and speed - (`speed` + `1.25×`). Audio and aspect ratio have no chip: they are + (`speed` + `1.25×`). A chip's accessible name and tooltip carry its + value (`SUBTITLES_TOOLTIP` "Subtitles: English", `SPEED_TOOLTIP` + "Speed: 1.25×"); `tune` has `aria-haspopup="dialog"` and + `aria-expanded`. Audio and aspect ratio have no chip: they are panel-only. A chip click opens the panel **focused on its group** (`settingsFocus`; the group scrolls into view and wears a brief ring); right-click or long-press on the subtitle chip toggles subtitles without @@ -476,12 +484,34 @@ the panel the moment the last group disappears. picture and recording stay in the compact dock — the mock shows only `tune` + fullscreen there, but those two are engine features a viewer needs without opening anything. -- **Inside**: list groups (audio, subtitles, quality) use `menuitemradio` - rows with a check mark and a cyan selection; segmented groups (speed, - aspect) use `radio` items with a violet selection, and a selected default - (`1×`, the first aspect preset) stays neutral. The subtitle group carries - the load-file action and the delay / size / color sections that the - popover used to hold (same `player-controls-load-subtitle`, +- **Inside**: every choice is a `radio` in a `radiogroup` + (`SettingsRadioGroupDirective` / `SettingsRadioDirective`). List groups + (audio, subtitles, quality) are rows with a check mark and a cyan + selection; segmented groups (speed, aspect, subtitle size) have a violet + selection, and a selected default (`1×`, the first aspect preset) stays + neutral. Each group is one Tab stop — the checked option as the engine + reports it, else the first — and arrows, Home and End move focus with a + CDK `FocusKeyManager` (wrapping; the horizontal arrows follow `direction`) + and check the option they reach, as a native radio group does: the + directive clicks it, so the template's handler applies the choice, even + onto an option the engine still reports as checked: returning to it must + cancel a switch that is still pending. Focus + changes also write the roving `tabindex` immediately, because a quick + Shift+Tab, Tab can arrive before change detection updates the bindings. The dialog is named by its `h2` title (`aria-labelledby`), each + radio group by its `h3` group heading or `h4` subheading, and the delay + buttons form a labelled `group`. Headings read `--pc-text-secondary` on + `--pc-glass-bg-dense` (`rgba(12,16,23,.86)`), 4.5:1 or more even over a + white frame. They wrap with `overflow-wrap: anywhere` and `hyphens: auto`, + so one long word (German "Wiedergabegeschwindigkeit") breaks inside the + sheet's 84px heading column. Hyphenation needs a `lang` on ``. + With subtitles on but no track marked selected yet (the engine can report + the switch before the track list), the subtitle chip reads "On" + (`SUBTITLES_ON`), never "Off". + Every focused option shows the `--pc-text` ring; a selected swatch has a + white border with a dark inner gap, and focus adds an outer ring. The + subtitle group carries the load-file action (a plain button outside the + radio group) and the delay / size / color sections that the popover used + to hold (same `player-controls-load-subtitle`, `player-controls-subtitle-delay`, `player-controls-subtitle-style` test ids). The panel is a `role="dialog"` with `tabindex="-1"`: opened from the keyboard (the opener is `:focus-visible`) it takes focus, a pointer @@ -1639,6 +1669,7 @@ libs/ui/playback/src/lib/player-controls/ ├── player-settings-panel.component.ts ├── player-settings-panel.component.html ├── player-settings-panel.component.scss +├── settings-radio-group.directive.ts ├── controls-fullscreen.ts ├── controls-menu-selection.ts ├── controls-menu-state.ts 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 f695c4453..fcfc38f57 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 @@ -366,6 +366,16 @@ settings.available() && layout.roomy() && !settings.isOpen() ) { @if (settings.groups().subtitles) { + + @let subtitleValue = + settings.subtitleLabel() ?? + ((settings.subtitlesOn() + ? 'EMBEDDED_MPV.PLAYER.SUBTITLES_ON' + : 'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' + ) | translate); } @@ -407,7 +417,9 @@ data-test-id="player-controls-speed-chip" (click)="settings.open('speed')" [attr.aria-label]=" - 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate + 'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP' + | translate + : { speed: settings.speedLabel() } " [matTooltip]=" 'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP' @@ -434,6 +446,7 @@ " data-test-id="player-controls-settings-button" (click)="settings.toggle()" + aria-haspopup="dialog" [attr.aria-expanded]="settings.isOpen()" [attr.aria-label]=" 'EMBEDDED_MPV.PLAYER.SETTINGS' | translate 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 e721e8735..7bd311009 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 @@ -27,6 +27,9 @@ --pc-track: rgba(255, 255, 255, 0.18); --pc-hover: rgba(255, 255, 255, 0.1); --pc-glass-bg: rgba(12, 16, 23, 0.72); + // The settings panel carries small secondary text over whatever frame is + // playing: denser glass keeps it at 4.5:1 even over a white frame. + --pc-glass-bg-dense: rgba(12, 16, 23, 0.86); --pc-glass-border: rgba(255, 255, 255, 0.09); --pc-font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; @@ -493,10 +496,20 @@ --mat-icon-button-state-layer-size: 40px; --mat-icon-button-container-shape: 12px; --mat-icon-button-hover-state-layer-opacity: 0; + // The ring below is the focus indicator; Material's focus layer would + // take its colour from the app theme. + --mat-icon-button-focus-state-layer-opacity: 0; border-radius: 12px; transition: background 120ms ease-out; } +// Keyboard focus in the overlay's own text colour, so it shows on video in +// both app themes. The active `tune` button keeps its accent fill under it. +:host :is(button[mat-icon-button]:focus-visible) { + outline: 2px solid var(--pc-text); + outline-offset: 2px; +} + :host :is(button[mat-icon-button]) mat-icon { color: var(--pc-text); } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts index aafb3ff6b..7aebb3175 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts @@ -219,7 +219,9 @@ describe('PlayerControlsComponent settings panel', () => { 'player-controls__settings--sheet' ); expect(panel?.getAttribute('role')).toBe('dialog'); - expect(panel?.getAttribute('aria-label')).toBe('Settings'); + const title = panel?.querySelector('h2'); + expect(panel?.getAttribute('aria-labelledby')).toBe(title?.id); + expect(title?.textContent?.trim()).toBe('Settings'); expect( query('[data-test-id="player-settings-speed"]')?.classList ).toContain('player-settings__group--focused'); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts index 09585bc5e..c33b77337 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts @@ -99,12 +99,14 @@ describe('PlayerControlsComponent subtitle settings', () => { }); it('renders the subtitle chip with zero tracks when external loading exists', () => { - expect(query('[aria-label="Subtitles"]')).toBeNull(); + const chip = () => + query('[data-test-id="player-controls-subtitle-chip"]'); + expect(chip()).toBeNull(); setCapabilities({ externalSubtitles: true }); fixture.detectChanges(); - expect(query('[aria-label="Subtitles"]')).not.toBeNull(); + expect(chip()).not.toBeNull(); openSubtitleMenu(); // No track list entries: no Off row without a selectable track… expect(query('.player-settings__option--selected')).toBeNull(); 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 62bee3157..ac40ad085 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 @@ -30,6 +30,8 @@ const STYLE_SOURCES = new Map( const HOST_STYLES = STYLE_SOURCES.get('player-controls.component.scss') ?? ''; const TIMELINE_STYLES = STYLE_SOURCES.get('player-timeline.component.scss') ?? ''; +const SETTINGS_STYLES = + STYLE_SOURCES.get('player-settings-panel.component.scss') ?? ''; type Rgb = [number, number, number]; @@ -52,6 +54,24 @@ function paletteColor(token: string): { rgb: Rgb; alpha: number } { throw new Error(`${token} is not a literal palette colour: "${value}"`); } +/** A translucent palette colour composited over an opaque frame. */ +function over(color: { rgb: Rgb; alpha: number }, frame: Rgb): Rgb { + return color.rgb.map( + (channel, index) => + channel * color.alpha + frame[index] * (1 - color.alpha) + ) as Rgb; +} + +/** The declarations of the first rule whose selector list is exactly `selector`. */ +function ruleBody(source: string, selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return ( + source.match( + new RegExp(`(?:^|[}\\s])${escaped}\\s*\\{([^}]*)\\}`) + )?.[1] ?? '' + ); +} + function contrastRatio(foreground: Rgb, background: Rgb): number { const luminance = (rgb: Rgb): number => { const [red, green, blue] = rgb.map((channel) => { @@ -130,6 +150,67 @@ describe('player controls overlay palette', () => { ); }); + it('keeps the settings headings at 4.5:1 on the panel glass over bright frames', () => { + const midGrey: Rgb = [128, 128, 128]; + const white: Rgb = [255, 255, 255]; + const glass = paletteColor('--pc-glass-bg-dense'); + const secondary = paletteColor('--pc-text-secondary').rgb; + + expect(ruleBody(SETTINGS_STYLES, ':host')).toMatch( + /background:\s*var\(--pc-glass-bg-dense\b/ + ); + expect( + ruleBody( + SETTINGS_STYLES, + '.player-settings__heading,\n.player-settings__subheading' + ) + ).toMatch(/color:\s*var\(--pc-text-secondary\b/); + expect(SETTINGS_STYLES).not.toMatch( + /__(sub)?heading\s*\{[^}]*--pc-text-tertiary/ + ); + for (const frame of [midGrey, white]) { + expect( + contrastRatio(secondary, over(glass, frame)) + ).toBeGreaterThanOrEqual(4.5); + } + // The tertiary step the headings used to read failed on mid-grey. + expect( + contrastRatio( + paletteColor('--pc-text-tertiary').rgb, + over(paletteColor('--pc-glass-bg'), midGrey) + ) + ).toBeLessThan(4.5); + }); + + it('draws keyboard focus on icon buttons in the overlay text colour', () => { + const ring = /outline:\s*2px solid var\(--pc-text[,)]/; + for (const source of [HOST_STYLES, SETTINGS_STYLES]) { + expect( + ruleBody( + source, + ':host :is(button[mat-icon-button]:focus-visible)' + ) + ).toMatch(ring); + // Material's focus layer takes the app theme's colour. + expect( + ruleBody(source, ':host :is(button[mat-icon-button])') + ).toMatch(/--mat-icon-button-focus-state-layer-opacity:\s*0;/); + } + }); + + it('tells a focused selected swatch from one that is only selected', () => { + const selected = ruleBody( + SETTINGS_STYLES, + '.player-settings__swatch--selected' + ); + const focused = ruleBody( + SETTINGS_STYLES, + '.player-settings__swatch:focus-visible' + ); + expect(selected).not.toMatch(/outline/); + expect(focused).toMatch(/outline:\s*2px solid var\(--pc-text[,)]/); + }); + 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-settings-panel.a11y.spec.ts b/libs/ui/playback/src/lib/player-controls/player-settings-panel.a11y.spec.ts new file mode 100644 index 000000000..1e2e25b98 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.a11y.spec.ts @@ -0,0 +1,355 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import { PlayerControlsComponent } from './player-controls.component'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, + PlayerController, +} from './player-controls.model'; + +function createFakeController() { + const capabilities: WritableSignal = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + audioTracks: true, + subtitles: true, + externalSubtitles: true, + subtitleDelay: true, + subtitleStyle: true, + qualityLevels: true, + playbackSpeed: true, + aspectRatio: true, + }); + const state: WritableSignal = signal({ + ...createEmptyControlsState(), + audioTracks: [ + { id: 1, label: 'English', selected: true }, + { id: 2, label: 'German', selected: false }, + ], + subtitleTracks: [{ id: 5, label: 'Russian', selected: false }], + qualityLevels: [ + { id: 0, label: '720p', selected: false }, + { id: 1, label: '1080p', selected: false }, + ], + }); + const commands = { + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + } as unknown as jest.Mocked; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +const KEY_CODES: Record = { + ArrowLeft: 37, + ArrowUp: 38, + ArrowRight: 39, + ArrowDown: 40, + Home: 36, + End: 35, +}; + +/** A real key press carries its legacy `keyCode`, which CDK key managers read. */ +function press(target: HTMLElement, key: string): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'keyCode', { value: KEY_CODES[key] }); + target.dispatchEvent(event); + return event; +} + +/** + * The settings panel as assistive technology and the keyboard meet it: a + * dialog named by its heading, radio groups named by theirs with one Tab + * stop each, arrow keys that move focus without applying, and dock chips + * whose names carry their values. + */ +describe('PlayerSettingsPanelComponent accessibility', () => { + const originalResizeObserver = globalThis.ResizeObserver; + let fixture: ComponentFixture; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + const radios = (group: string) => + Array.from( + fixture.nativeElement.querySelectorAll( + `[data-test-id="player-settings-${group}"] [role="radio"]` + ) as NodeListOf + ); + // List rows carry a check icon beside their label, segments are text + // and colour swatches are named by `aria-label`. + const label = (radio: Element) => + radio.getAttribute('aria-label') ?? + (radio.querySelector('span') ?? radio).textContent?.trim(); + const tabStops = (group: string) => + radios(group) + .filter((radio) => radio.getAttribute('tabindex') === '0') + .map(label); + const openPanel = () => { + component.settings.open(); + fixture.detectChanges(); + }; + + beforeEach(async () => { + globalThis.ResizeObserver = class { + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } + } as unknown as typeof ResizeObserver; + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { + SETTINGS: 'Settings', + AUDIO_TRACKS: 'Audio tracks', + SUBTITLES: 'Subtitles', + SUBTITLES_OFF: 'Off', + SUBTITLES_ON: 'On', + SUBTITLES_TOOLTIP: 'Subtitles: {{subtitles}}', + SUBTITLE_DELAY: 'Subtitle delay', + SUBTITLE_SIZE: 'Subtitle size', + SUBTITLE_COLOR: 'Subtitle color', + SUBTITLE_COLOR_DEFAULT: 'Default', + QUALITY: 'Quality', + QUALITY_AUTO: 'Auto', + PLAYBACK_SPEED: 'Playback speed', + SPEED_TOOLTIP: 'Speed: {{speed}}', + ASPECT_RATIO: 'Aspect ratio', + ASPECT_DEFAULT: 'Default', + }, + }, + }); + translate.use('en'); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('controller', fake.controller); + fixture.detectChanges(); + document.body.appendChild(fixture.nativeElement); + }); + + afterEach(() => { + fixture.nativeElement.remove(); + fixture.destroy(); + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('names the dialog and every radio group by a real heading', () => { + openPanel(); + const panel = query('[data-test-id="player-controls-settings-panel"]'); + const title = panel?.querySelector('h2'); + expect(panel?.getAttribute('aria-labelledby')).toBe(title?.id); + expect(title?.textContent?.trim()).toBe('Settings'); + + const groups = Array.from( + panel?.querySelectorAll('[role="radiogroup"]') ?? [] + ).map((group) => { + const heading = document.getElementById( + group.getAttribute('aria-labelledby') ?? '' + ); + return `${heading?.tagName} ${heading?.textContent?.trim()}`; + }); + expect(groups).toEqual([ + 'H3 Audio tracks', + 'H3 Subtitles', + 'H4 Subtitle size', + 'H4 Subtitle color', + 'H3 Quality', + 'H3 Playback speed', + 'H3 Aspect ratio', + ]); + expect( + document.getElementById( + query( + '[data-test-id="player-controls-subtitle-delay"] [role="group"]' + )?.getAttribute('aria-labelledby') ?? '' + )?.textContent + ).toContain('Subtitle delay'); + }); + + it('offers radios only, with the load-file action outside the group', () => { + openPanel(); + const panel = query( + '[data-test-id="player-controls-settings-panel"]' + ) as HTMLElement; + + expect(panel.querySelectorAll('[role^="menuitem"]')).toHaveLength(0); + const radioParents = Array.from( + panel.querySelectorAll('[role="radio"]') + ).map((radio) => radio.parentElement?.getAttribute('role')); + expect(radioParents.length).toBeGreaterThan(0); + expect(new Set(radioParents)).toEqual(new Set(['radiogroup'])); + + const load = query('[data-test-id="player-controls-load-subtitle"]'); + expect(load?.hasAttribute('role')).toBe(false); + expect(load?.closest('[role="radiogroup"]')).toBeNull(); + }); + + it('gives each group one Tab stop on its checked option, else the first', () => { + fake.state.update((state) => ({ ...state, playbackSpeed: 1.1 })); + openPanel(); + + expect(tabStops('audio')).toEqual(['English']); + // Tracks, then the subtitle size and colour groups of the section. + expect(tabStops('subtitles')).toEqual(['Off', '100%', 'Default']); + expect(tabStops('quality')).toEqual(['Auto']); + // No preset matches 1.1×: the first option keeps the group reachable. + expect(tabStops('speed')).toEqual(['0.5×']); + expect(tabStops('aspect')).toEqual(['Default']); + }); + + it('moves focus with the arrow keys, Home and End and checks the option reached', () => { + openPanel(); + const checked = radios('speed').find( + (radio) => radio.getAttribute('aria-checked') === 'true' + ) as HTMLButtonElement; + checked.focus(); + expect(checked.textContent?.trim()).toBe('1×'); + + expect(press(checked, 'ArrowRight').defaultPrevented).toBe(true); + fixture.detectChanges(); + expect(document.activeElement?.textContent?.trim()).toBe('1.25×'); + expect(tabStops('speed')).toEqual(['1.25×']); + + // The ends wrap, as in a native radio group. + const reached = [ + 'ArrowDown', + 'ArrowUp', + 'ArrowLeft', + 'End', + 'ArrowRight', + 'ArrowLeft', + 'Home', + ].map((key) => { + press(document.activeElement as HTMLElement, key); + return document.activeElement?.textContent?.trim(); + }); + expect(reached).toEqual([ + '1.5×', + '1.25×', + '1×', + '2×', + '0.5×', + '2×', + '0.5×', + ]); + // Every move applies its option, 1× included: the (fake) engine + // still reports 1× checked, but 1.25× is the pending request. + expect( + fake.commands.setPlaybackSpeed.mock.calls.map(([speed]) => speed) + ).toEqual([1.25, 1.5, 1.25, 1, 2, 0.5, 2, 0.5]); + }); + + it('keeps arrow keys inside their own group', () => { + openPanel(); + const [english, german] = radios('audio'); + english.focus(); + press(english, 'ArrowDown'); + expect(document.activeElement).toBe(german); + press(german, 'ArrowDown'); + expect(document.activeElement).toBe(english); + // Returning before the engine confirms the first switch applies + // English again; otherwise the pending switch would win. + expect(fake.commands.setAudioTrack.mock.calls).toEqual([[2], [1]]); + expect(fake.commands.setPlaybackSpeed).not.toHaveBeenCalled(); + }); + + it('hands the Tab stop back to the reported checked option when focus leaves', () => { + // The fake engine never confirms the switch the arrow key requested, + // so English stays the checked track. + openPanel(); + const [english, german] = radios('audio'); + english.focus(); + press(english, 'ArrowDown'); + fixture.detectChanges(); + expect(tabStops('audio')).toEqual(['German']); + expect(german.tabIndex).toBe(0); + + query('[data-test-id="player-settings-close"]')?.focus(); + fixture.detectChanges(); + expect(tabStops('audio')).toEqual(['English']); + }); + + it('reverses the horizontal arrows in a right-to-left layout', () => { + openPanel(); + const speed = radios('speed'); + (speed[0].parentElement as HTMLElement).style.direction = 'rtl'; + speed[2].focus(); + + press(speed[2], 'ArrowLeft'); + expect(document.activeElement).toBe(speed[3]); + press(speed[3], 'ArrowRight'); + expect(document.activeElement).toBe(speed[2]); + }); + + it('names the chips with their current values', () => { + const subtitleChip = () => + query('[data-test-id="player-controls-subtitle-chip"]'); + const speedChip = () => + query('[data-test-id="player-controls-speed-chip"]'); + expect(subtitleChip()?.getAttribute('aria-label')).toBe( + 'Subtitles: Off' + ); + expect(speedChip()?.getAttribute('aria-label')).toBe('Speed: 1×'); + + fake.state.update((state) => ({ + ...state, + subtitleTracks: [{ id: 5, label: 'Russian', selected: true }], + subtitlesEnabled: true, + playbackSpeed: 1.25, + })); + fixture.detectChanges(); + + expect(subtitleChip()?.getAttribute('aria-label')).toBe( + 'Subtitles: Russian' + ); + expect(speedChip()?.getAttribute('aria-label')).toBe('Speed: 1.25×'); + }); + + it('names the subtitle chip "On" while no enabled track is marked selected', () => { + // The engine can report subtitles on before the track list marks the + // selected track; the chip must not claim they are off. + fake.state.update((state) => ({ + ...state, + subtitleTracks: [{ id: 5, label: 'Russian', selected: false }], + subtitlesEnabled: true, + })); + fixture.detectChanges(); + + const chip = query('[data-test-id="player-controls-subtitle-chip"]'); + expect(chip?.getAttribute('aria-label')).toBe('Subtitles: On'); + expect( + chip?.querySelector('.player-controls__chip-label')?.textContent + ).toContain('On'); + }); + + it('announces that the tune button opens a dialog', () => { + const tune = query('[data-test-id="player-controls-settings-button"]'); + expect(tune?.getAttribute('aria-haspopup')).toBe('dialog'); + expect(tune?.getAttribute('aria-expanded')).toBe('false'); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html index f58ac738b..f6692d6ce 100644 --- a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html @@ -2,7 +2,9 @@ }
-
{{ title() }}
+

+ {{ title() }} +

- } - @for (track of state().subtitleTracks; track track.id) { - - } - @if (capabilities().externalSubtitles) { - - } -
+ + @if (state().subtitleTracks.length > 0) { +
+ @if (capabilities().subtitles) { + + } + @for (track of state().subtitleTracks; track track.id) { + + } +
+ } + @if (capabilities().externalSubtitles) { + + + } @if (capabilities().subtitleDelay) {
-
+

{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }} -

-
+ +
-
+

{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }} -

+
@for ( preset of subtitleColorPresets; @@ -262,8 +266,10 @@ ) {