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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-30 20:45:12 +02:00
1 parent 0524e72b9f
commit cdc208dbff
37 files changed
+1369 -256

No files matched your search

@@ -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.
@@ -0,0 +1,404 @@
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<FocusStop | null> {
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<boolean> {
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<FocusStop[]> {
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 without applying; Space applies. -----------
const speedGroup = panel.locator(
'[data-test-id="player-settings-speed"] [role="radiogroup"]'
);
const rateBefore = await video.evaluate(
(el: HTMLVideoElement) => el.playbackRate
);
await page.keyboard.press('ArrowRight');
const moved = await focusStop(page);
expect(moved?.role).toBe('radio');
expect(moved?.checked).toBe('false');
expect(
await video.evaluate((el: HTMLVideoElement) => el.playbackRate)
).toBe(rateBefore);
await page.keyboard.press('Space');
await expect
.poll(() =>
video.evaluate((el: HTMLVideoElement) => el.playbackRate)
)
.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 page.keyboard.press('ArrowLeft');
expect((await focusStop(page))?.label).toBe('2×');
await expect(speedGroup.locator('[tabindex="0"]')).toHaveText('2×');
// Leaving and re-entering lands on the checked option again.
await pressTab(page, browserName, 'backward');
await pressTab(page, browserName);
expect((await focusStop(page))?.label).toBe(moved?.label);
// --- A focused selected swatch differs from a selected one. -------
await tabUntil(page, browserName, {
target: '.player-settings__swatches',
direction: 'backward',
max: 5,
});
const focusedSelected = panel.locator('.player-settings__swatch:focus');
await expect(focusedSelected).toHaveAttribute('aria-checked', 'true');
await expect(focusedSelected).toHaveCSS('outline-style', 'solid');
await page.keyboard.press('ArrowRight');
const selectedOnly = panel.locator(
'.player-settings__swatch[aria-checked="true"]:not(:focus)'
);
await expect(selectedOnly).toHaveCount(1);
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`),
});
// A keyboard-focused swatch shows its tooltip, and Material spends
// the next Escape on a visible tooltip: close from a speed radio,
// which has none, once the swatch's tooltip has gone.
await pressTab(page, browserName);
expect(
await focusIsInside(page, '[data-test-id="player-settings-speed"]')
).toBe(true);
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`),
});
});
}
+8 -97
View File
@@ -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<void> {
const clip = readFileSync(
join(__dirname, 'fixtures/playback/episode.webm')
);
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
const range = /^bytes=(\d*)-(\d*)$/.exec(
route.request().headers()['range'] ?? ''
);
const last = clip.length - 1;
const start = range?.[1]
? Number(range[1])
: range?.[2]
? Math.max(0, clip.length - Number(range[2]))
: 0;
const end =
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
await route.fulfill({
status: range ? 206 : 200,
headers: {
'content-type': 'video/webm',
'accept-ranges': 'bytes',
'content-length': String(end - start + 1),
...(range
? {
'content-range': `bytes ${start}-${end}/${clip.length}`,
}
: {}),
},
body: clip.subarray(start, end + 1),
});
});
}
async function selectHtml5Player(page: Page): Promise<void> {
await page.goto('/workspace/settings/playback');
const select = page.locator('[data-test-id="select-video-player"]');
await expect(select).toBeVisible();
const previous = await select.innerText();
await select.click();
await page
.getByRole('option', { name: 'HTML5 video player', exact: true })
.click();
if (!previous.includes('HTML5 video player')) {
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
}
}
async function importPlaylist(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
]);
await expect(page.getByText('1 channels')).toBeVisible();
}
async function startClip(page: Page) {
await page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: 'Settings Clip' })
.click();
const view = page.locator('app-web-player-view');
const video = view.locator('video');
await expect
.poll(() =>
video.evaluate(
(el: HTMLVideoElement) =>
Number.isFinite(el.duration) && el.duration > 5
)
)
.toBe(true);
// Reveal the dock and keep it revealed for the assertions below.
await view.hover();
const controls = view.locator('app-player-controls');
await controls.getByRole('button', { name: 'Pause', exact: true }).click();
return { view, video, controls };
}
test('@web @playback settings panel opens from the speed chip and applies in place', async ({
page,
}) => {
@@ -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);
@@ -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<void> {
const clip = readFileSync(
join(__dirname, 'fixtures/playback/episode.webm')
);
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
const range = /^bytes=(\d*)-(\d*)$/.exec(
route.request().headers()['range'] ?? ''
);
const last = clip.length - 1;
const start = range?.[1]
? Number(range[1])
: range?.[2]
? Math.max(0, clip.length - Number(range[2]))
: 0;
const end =
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
await route.fulfill({
status: range ? 206 : 200,
headers: {
'content-type': 'video/webm',
'accept-ranges': 'bytes',
'content-length': String(end - start + 1),
...(range
? {
'content-range': `bytes ${start}-${end}/${clip.length}`,
}
: {}),
},
body: clip.subarray(start, end + 1),
});
});
}
export async function selectPlayer(
page: Page,
player = 'HTML5 video player'
): Promise<void> {
await page.goto('/workspace/settings/playback');
const select = page.locator('[data-test-id="select-video-player"]');
await expect(select).toBeVisible();
const previous = await select.innerText();
await select.click();
await page.getByRole('option', { name: 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<string> {
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 };
}
+1
View File
@@ -626,6 +626,7 @@
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
"PLAYBACK_SPEED": "سرعة التشغيل",
"SPEED_TOOLTIP": "السرعة: {{speed}}",
"SUBTITLES_TOOLTIP": "الترجمات: {{subtitles}}",
"ASPECT_RATIO": "نسبة العرض إلى الارتفاع",
"ASPECT_TOOLTIP": "النسبة: {{aspect}}",
"ASPECT_DEFAULT": "افتراضي",
+1
View File
@@ -626,6 +626,7 @@
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
"PLAYBACK_SPEED": "سرعة التشغيل",
"SPEED_TOOLTIP": "السرعة: {{speed}}",
"SUBTITLES_TOOLTIP": "الترجمة: {{subtitles}}",
"ASPECT_RATIO": "نسبة الأبعاد",
"ASPECT_TOOLTIP": "الأبعاد: {{aspect}}",
"ASPECT_DEFAULT": "افتراضي",
+1
View File
@@ -626,6 +626,7 @@
"SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}",
"PLAYBACK_SPEED": "Хуткасць прайгравання",
"SPEED_TOOLTIP": "Хуткасць: {{speed}}",
"SUBTITLES_TOOLTIP": "Субтытры: {{subtitles}}",
"ASPECT_RATIO": "Суадносіны бакоў",
"ASPECT_TOOLTIP": "Суадносіны: {{aspect}}",
"ASPECT_DEFAULT": "Па змаўчанні",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,7 @@
"SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}",
"PLAYBACK_SPEED": "Ταχύτητα αναπαραγωγής",
"SPEED_TOOLTIP": "Ταχύτητα: {{speed}}",
"SUBTITLES_TOOLTIP": "Υπότιτλοι: {{subtitles}}",
"ASPECT_RATIO": "Αναλογία εικόνας",
"ASPECT_TOOLTIP": "Αναλογία: {{aspect}}",
"ASPECT_DEFAULT": "Προεπιλογή",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,7 @@
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
"PLAYBACK_SPEED": "再生速度",
"SPEED_TOOLTIP": "速度:{{speed}}",
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
"ASPECT_RATIO": "アスペクト比",
"ASPECT_TOOLTIP": "アスペクト比:{{aspect}}",
"ASPECT_DEFAULT": "デフォルト",
+1
View File
@@ -626,6 +626,7 @@
"SUBTITLE_TRACK_FALLBACK": "자막 {{index}}",
"PLAYBACK_SPEED": "재생 속도",
"SPEED_TOOLTIP": "속도: {{speed}}",
"SUBTITLES_TOOLTIP": "자막: {{subtitles}}",
"ASPECT_RATIO": "화면 비율",
"ASPECT_TOOLTIP": "비율: {{aspect}}",
"ASPECT_DEFAULT": "기본",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,7 @@
"SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}",
"PLAYBACK_SPEED": "Скорость воспроизведения",
"SPEED_TOOLTIP": "Скорость: {{speed}}",
"SUBTITLES_TOOLTIP": "Субтитры: {{subtitles}}",
"ASPECT_RATIO": "Соотношение сторон",
"ASPECT_TOOLTIP": "Соотношение: {{aspect}}",
"ASPECT_DEFAULT": "По умолчанию",
+1
View File
@@ -626,6 +626,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",
+1
View File
@@ -626,6 +626,7 @@
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
"PLAYBACK_SPEED": "播放速度",
"SPEED_TOOLTIP": "速度:{{speed}}",
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
"ASPECT_RATIO": "宽高比",
"ASPECT_TOOLTIP": "宽高比:{{aspect}}",
"ASPECT_DEFAULT": "默认",
+1
View File
@@ -626,6 +626,7 @@
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
"PLAYBACK_SPEED": "播放速度",
"SPEED_TOOLTIP": "速度:{{speed}}",
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
"ASPECT_RATIO": "長寬比",
"ASPECT_TOOLTIP": "長寬比:{{aspect}}",
"ASPECT_DEFAULT": "預設",
+3 -1
View File
@@ -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,
+36 -9
View File
@@ -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
@@ -442,7 +447,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
@@ -460,12 +468,30 @@ 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, else the first —
and arrows, Home and End move focus with a CDK `FocusKeyManager`
(wrapping; the horizontal arrows follow `direction`). Moving focus does not
apply: a choice is applied to the playing stream at once and a track or
quality switch rebuffers, so Space / Enter checks, as in a toolbar radio
group. 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 `<html>`.
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
@@ -1613,6 +1639,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
@@ -366,6 +366,11 @@
settings.available() && layout.roomy() && !settings.isOpen()
) {
@if (settings.groups().subtitles) {
@let subtitleValue =
settings.subtitleLabel() ??
('EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate);
<!-- The name carries the value ("Subtitles: English"):
the label alone is only the value. -->
<button
type="button"
class="player-controls__chip"
@@ -378,10 +383,12 @@
settings.onSubtitleChipContextMenu($event)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
'EMBEDDED_MPV.PLAYER.SUBTITLES_TOOLTIP'
| translate: { subtitles: subtitleValue }
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
'EMBEDDED_MPV.PLAYER.SUBTITLES_TOOLTIP'
| translate: { subtitles: subtitleValue }
"
matTooltipPosition="above"
>
@@ -391,9 +398,7 @@
: 'closed_caption_disabled'
}}</mat-icon>
<span class="player-controls__chip-label">{{
settings.subtitleLabel() ??
('EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
| translate)
subtitleValue
}}</span>
</button>
}
@@ -407,7 +412,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 +441,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
@@ -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);
}
@@ -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');
@@ -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();
@@ -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');
@@ -0,0 +1,324 @@
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<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
audioTracks: true,
subtitles: true,
externalSubtitles: true,
subtitleDelay: true,
subtitleStyle: true,
qualityLevels: true,
playbackSpeed: true,
aspectRatio: true,
});
const state: WritableSignal<PlayerControlsState> = 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<PlayerControlsCommands>;
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
const KEY_CODES: Record<string, number> = {
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<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
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<HTMLButtonElement>
);
// 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_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 without applying a choice', () => {
openPanel();
const speed = radios('speed');
const checked = 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×']);
press(document.activeElement as HTMLElement, 'ArrowDown');
expect(document.activeElement?.textContent?.trim()).toBe('1.5×');
press(document.activeElement as HTMLElement, 'ArrowUp');
press(document.activeElement as HTMLElement, 'ArrowLeft');
expect(document.activeElement?.textContent?.trim()).toBe('1×');
press(document.activeElement as HTMLElement, 'End');
expect(document.activeElement?.textContent?.trim()).toBe('2×');
// The ends wrap, as in a native radio group.
press(document.activeElement as HTMLElement, 'ArrowRight');
expect(document.activeElement?.textContent?.trim()).toBe('0.5×');
press(document.activeElement as HTMLElement, 'ArrowLeft');
expect(document.activeElement?.textContent?.trim()).toBe('2×');
press(document.activeElement as HTMLElement, 'Home');
expect(document.activeElement?.textContent?.trim()).toBe('0.5×');
expect(fake.commands.setPlaybackSpeed).not.toHaveBeenCalled();
(document.activeElement as HTMLButtonElement).click();
expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(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);
expect(fake.commands.setAudioTrack).not.toHaveBeenCalled();
});
it('hands the Tab stop back to the checked option when focus leaves', () => {
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('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');
});
});
@@ -2,7 +2,9 @@
<div class="player-settings__grip" aria-hidden="true"></div>
}
<div class="player-settings__header">
<div class="player-settings__title">{{ title() }}</div>
<h2 class="player-settings__title" [id]="headingId('title')">
{{ title() }}
</h2>
<button
mat-icon-button
type="button"
@@ -24,29 +26,26 @@
data-group="audio"
data-test-id="player-settings-audio"
>
<div class="player-settings__heading">
<h3 class="player-settings__heading" [id]="headingId('audio')">
<span
class="player-settings__dot player-settings__dot--cyan"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }}
</div>
</h3>
<div
class="player-settings__options"
role="group"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('audio')"
>
@for (track of state().audioTracks; track track.id) {
<button
type="button"
role="menuitemradio"
class="player-settings__option"
[appSettingsRadio]="track.selected"
[class.player-settings__option--selected]="
track.selected
"
[attr.aria-checked]="track.selected"
(click)="selection().audioTrack(track.id)"
>
<span>{{ track.label }}</span>
@@ -66,82 +65,86 @@
data-group="subtitles"
data-test-id="player-settings-subtitles"
>
<div class="player-settings__heading">
<h3 class="player-settings__heading" [id]="headingId('subtitles')">
<span
class="player-settings__dot player-settings__dot--cyan"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }}
</div>
<div
class="player-settings__options"
role="group"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate"
>
@if (
capabilities().subtitles &&
state().subtitleTracks.length > 0
) {
<button
type="button"
role="menuitemradio"
class="player-settings__option player-settings__option--default"
[class.player-settings__option--selected]="
!state().subtitlesEnabled
"
[attr.aria-checked]="!state().subtitlesEnabled"
(click)="selection().subtitleTrack(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate
}}</span>
@if (!state().subtitlesEnabled) {
<mat-icon>check</mat-icon>
}
</button>
}
@for (track of state().subtitleTracks; track track.id) {
<button
type="button"
role="menuitemradio"
class="player-settings__option"
[class.player-settings__option--selected]="
track.selected
"
[attr.aria-checked]="track.selected"
(click)="selection().subtitleTrack(track.id)"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
@if (capabilities().externalSubtitles) {
<button
type="button"
role="menuitem"
class="player-settings__option player-settings__option--action"
data-test-id="player-controls-load-subtitle"
(click)="loadExternalSubtitle()"
>
<span>{{
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE' | translate
}}</span>
<mat-icon>upload_file</mat-icon>
</button>
}
</div>
</h3>
@if (state().subtitleTracks.length > 0) {
<div
class="player-settings__options"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('subtitles')"
>
@if (capabilities().subtitles) {
<button
type="button"
class="player-settings__option player-settings__option--default"
[appSettingsRadio]="!state().subtitlesEnabled"
[class.player-settings__option--selected]="
!state().subtitlesEnabled
"
(click)="selection().subtitleTrack(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate
}}</span>
@if (!state().subtitlesEnabled) {
<mat-icon>check</mat-icon>
}
</button>
}
@for (track of state().subtitleTracks; track track.id) {
<button
type="button"
class="player-settings__option"
[appSettingsRadio]="track.selected"
[class.player-settings__option--selected]="
track.selected
"
(click)="selection().subtitleTrack(track.id)"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
}
@if (capabilities().externalSubtitles) {
<!-- An action, not a choice: kept out of the radio group. -->
<button
type="button"
class="player-settings__option player-settings__option--action"
data-test-id="player-controls-load-subtitle"
(click)="loadExternalSubtitle()"
>
<span>{{
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE' | translate
}}</span>
<mat-icon>upload_file</mat-icon>
</button>
}
@if (capabilities().subtitleDelay) {
<div
class="player-settings__subsection"
data-test-id="player-controls-subtitle-delay"
>
<div class="player-settings__subheading">
<h4
class="player-settings__subheading"
[id]="headingId('subtitle-delay')"
>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }}
</div>
<div class="player-settings__delay-row">
</h4>
<div
class="player-settings__delay-row"
role="group"
[attr.aria-labelledby]="headingId('subtitle-delay')"
>
<button
mat-icon-button
type="button"
@@ -212,15 +215,16 @@
class="player-settings__subsection"
data-test-id="player-controls-subtitle-style"
>
<div class="player-settings__subheading">
<h4
class="player-settings__subheading"
[id]="headingId('subtitle-size')"
>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate }}
</div>
</h4>
<div
class="player-settings__seg"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate
"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('subtitle-size')"
>
@for (
preset of subtitleSizePresets;
@@ -228,13 +232,12 @@
) {
<button
type="button"
role="radio"
class="player-settings__seg-item"
[class.player-settings__seg-item--selected]="
[appSettingsRadio]="
preset.value ===
state().subtitleStyle.sizePercent
"
[attr.aria-checked]="
[class.player-settings__seg-item--selected]="
preset.value ===
state().subtitleStyle.sizePercent
"
@@ -246,15 +249,16 @@
</button>
}
</div>
<div class="player-settings__subheading">
<h4
class="player-settings__subheading"
[id]="headingId('subtitle-color')"
>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }}
</div>
</h4>
<div
class="player-settings__swatches"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate
"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('subtitle-color')"
>
@for (
preset of subtitleColorPresets;
@@ -262,8 +266,10 @@
) {
<button
type="button"
role="radio"
class="player-settings__swatch"
[appSettingsRadio]="
preset.value === state().subtitleStyle.color
"
[class.player-settings__swatch--default]="
preset.value === null
"
@@ -271,9 +277,6 @@
preset.value === state().subtitleStyle.color
"
[style.--swatch-color]="preset.value"
[attr.aria-checked]="
preset.value === state().subtitleStyle.color
"
[attr.aria-label]="preset.label | translate"
[matTooltip]="preset.label | translate"
(click)="
@@ -294,26 +297,25 @@
data-group="quality"
data-test-id="player-settings-quality"
>
<div class="player-settings__heading">
<h3 class="player-settings__heading" [id]="headingId('quality')">
<span
class="player-settings__dot player-settings__dot--violet"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }}
</div>
</h3>
<div
class="player-settings__options"
role="group"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.QUALITY' | translate"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('quality')"
>
<button
type="button"
role="menuitemradio"
class="player-settings__option player-settings__option--default"
[appSettingsRadio]="state().qualityAutoEnabled"
[class.player-settings__option--selected]="
state().qualityAutoEnabled
"
[attr.aria-checked]="state().qualityAutoEnabled"
(click)="selection().qualityLevel(-1)"
>
<span>{{
@@ -326,12 +328,11 @@
@for (level of state().qualityLevels; track level.id) {
<button
type="button"
role="menuitemradio"
class="player-settings__option"
[appSettingsRadio]="level.selected"
[class.player-settings__option--selected]="
level.selected
"
[attr.aria-checked]="level.selected"
(click)="selection().qualityLevel(level.id)"
>
<span>{{ level.label }}</span>
@@ -351,34 +352,31 @@
data-group="speed"
data-test-id="player-settings-speed"
>
<div class="player-settings__heading">
<h3 class="player-settings__heading" [id]="headingId('speed')">
<span
class="player-settings__dot player-settings__dot--violet"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }}
</div>
</h3>
<div
class="player-settings__seg"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('speed')"
>
@for (preset of state().speedPresets; track preset.value) {
<button
type="button"
role="radio"
class="player-settings__seg-item"
[appSettingsRadio]="
preset.value === state().playbackSpeed
"
[class.player-settings__seg-item--default]="
preset.value === 1
"
[class.player-settings__seg-item--selected]="
preset.value === state().playbackSpeed
"
[attr.aria-checked]="
preset.value === state().playbackSpeed
"
(click)="selection().speed(preset.value)"
>
{{ preset.label }}
@@ -395,34 +393,31 @@
data-group="aspect"
data-test-id="player-settings-aspect"
>
<div class="player-settings__heading">
<h3 class="player-settings__heading" [id]="headingId('aspect')">
<span
class="player-settings__dot player-settings__dot--neutral"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }}
</div>
</h3>
<div
class="player-settings__seg"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('aspect')"
>
@for (preset of state().aspectPresets; track preset.value) {
<button
type="button"
role="radio"
class="player-settings__seg-item"
[appSettingsRadio]="
preset.value === state().aspectRatio
"
[class.player-settings__seg-item--default]="
preset.value === defaultAspect()
"
[class.player-settings__seg-item--selected]="
preset.value === state().aspectRatio
"
[attr.aria-checked]="
preset.value === state().aspectRatio
"
(click)="selection().aspect(preset.value)"
>
{{
@@ -17,7 +17,7 @@
box-sizing: border-box;
padding: 18px 16px;
color: var(--pc-text, #e7ecf3);
background: var(--pc-glass-bg, rgba(12, 16, 23, 0.72));
background: var(--pc-glass-bg-dense, rgba(12, 16, 23, 0.86));
border: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09));
border-radius: 20px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
@@ -69,7 +69,10 @@
}
.player-settings__title {
margin: 0;
overflow: hidden;
color: inherit;
font: inherit;
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
@@ -90,6 +93,9 @@
--mat-icon-button-container-shape: 10px;
--mat-icon-button-icon-size: 20px;
--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: 10px;
}
@@ -104,6 +110,13 @@
background: var(--pc-hover, rgba(255, 255, 255, 0.1));
}
// Inset like the option rows: the body is the scroll owner and would clip
// an outer ring on its edge.
:host :is(button[mat-icon-button]:focus-visible) {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: -2px;
}
:host :is(button[mat-icon-button][disabled]),
:host :is(button[mat-icon-button][disabled]) mat-icon {
color: var(--pc-text-tertiary, #6b7384);
@@ -147,17 +160,33 @@
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12);
}
// Small caps text on glass over video: the secondary ramp step keeps it at
// 4.5:1 or more over a mid-grey frame (the tertiary step measured 4.1:1 over
// black). One long word ("Wiedergabegeschwindigkeit") breaks instead of
// running out of the narrow sheet column; `hyphens` needs `<html lang>`.
.player-settings__heading,
.player-settings__subheading {
margin: 0;
color: var(--pc-text-secondary, #9aa3b2);
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.08em;
text-transform: uppercase;
overflow-wrap: anywhere;
hyphens: auto;
}
.player-settings__heading {
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px 4px;
color: var(--pc-text-tertiary, #6b7384);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.player-settings__heading .player-settings__dot {
flex: 0 0 auto;
}
.player-settings__dot {
@@ -300,12 +329,6 @@
.player-settings__subheading {
padding: 0 4px;
color: var(--pc-text-tertiary, #6b7384);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.player-settings__delay-row {
@@ -352,10 +375,22 @@
rgba(255, 255, 255, 0.08);
}
.player-settings__swatch:hover,
.player-settings__swatch:focus-visible,
.player-settings__swatch:hover {
border-color: #ffffff;
}
// Selected: a white ring with a dark gap inside it, which reads on every
// swatch colour, the white one included.
.player-settings__swatch--selected {
border-color: #ffffff;
box-shadow: inset 0 0 0 2px rgba(12, 16, 23, 0.9);
}
// Keyboard focus adds a ring outside the border, so a focused selected
// swatch looks different from one that is only selected.
.player-settings__swatch:focus-visible {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: 2px;
}
// Sheet density: two-column rows (heading | control) so five groups fit a
@@ -367,15 +402,15 @@
align-items: start;
}
// The heading column is narrow: let "Playback speed" wrap onto two lines
// instead of running under the control.
// The heading column is narrow: "Playback speed" wraps onto two lines, and a
// single long word breaks (see the heading rule), instead of running under
// the control.
:host(.player-settings--sheet) .player-settings__heading {
align-items: flex-start;
min-width: 0;
padding: 6px 0 0;
font-size: 10px;
line-height: 1.25;
white-space: normal;
}
:host(.player-settings--sheet) .player-settings__heading .player-settings__dot {
@@ -410,6 +445,13 @@
display: none;
}
// The load-file action sits outside the radio group: keep it in the control
// column, on the same pill background.
:host(.player-settings--sheet) .player-settings__option--action {
grid-column: 2;
background: rgba(255, 255, 255, 0.06);
}
:host(.player-settings--sheet) .player-settings__seg-item {
height: 24px;
font-size: 11.5px;
@@ -19,6 +19,10 @@ import type { ControlsSettings } from './controls-settings';
import type { SettingsGroup } from './controls-settings-groups';
import type { ControlsSubtitleSettings } from './controls-subtitle-settings';
import type { PlayerController } from './player-controls.model';
import {
SettingsRadioDirective,
SettingsRadioGroupDirective,
} from './settings-radio-group.directive';
import {
SUBTITLE_COLOR_PRESETS,
SUBTITLE_DELAY_STEP_SECONDS,
@@ -28,6 +32,8 @@ import {
export type PlayerSettingsPanelMode = 'panel' | 'sheet';
let nextPanelId = 0;
/**
* The settings surface behind the dock's `tune` button: audio, subtitles
* (tracks, file loading, delay, size and color), quality, speed and aspect
@@ -39,14 +45,21 @@ export type PlayerSettingsPanelMode = 'panel' | 'sheet';
selector: 'app-player-settings-panel',
templateUrl: './player-settings-panel.component.html',
styleUrl: './player-settings-panel.component.scss',
imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe],
imports: [
MatButtonModule,
MatIconModule,
MatTooltipModule,
SettingsRadioDirective,
SettingsRadioGroupDirective,
TranslatePipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-settings',
role: 'dialog',
tabindex: '-1',
'[class.player-settings--sheet]': 'mode() === "sheet"',
'[attr.aria-label]': 'title()',
'[attr.aria-labelledby]': 'headingId("title")',
'(focusin)': 'focusInside = true',
'(focusout)': 'onFocusOut($event)',
},
@@ -54,6 +67,8 @@ export type PlayerSettingsPanelMode = 'panel' | 'sheet';
export class PlayerSettingsPanelComponent implements OnDestroy {
private readonly host: HTMLElement =
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
/** Several players can be mounted at once: heading ids stay unique. */
private readonly idPrefix = `player-settings-${nextPanelId++}`;
readonly controller = input.required<PlayerController>();
readonly settings = input.required<ControlsSettings>();
readonly selection = input.required<ControlsMenuSelection>();
@@ -133,6 +148,11 @@ export class PlayerSettingsPanelComponent implements OnDestroy {
return this.focusGroup() === group;
}
/** The id of a heading that names the dialog or one of its groups. */
headingId(name: string): string {
return `${this.idPrefix}-${name}`;
}
loadExternalSubtitle(): void {
if (!this.capabilities().externalSubtitles) {
return;
@@ -0,0 +1,130 @@
import { FocusKeyManager, type FocusableOption } from '@angular/cdk/a11y';
import {
DestroyRef,
Directive,
ElementRef,
Injector,
computed,
contentChildren,
inject,
input,
signal,
} from '@angular/core';
/** One option of a {@link SettingsRadioGroupDirective}: a native button. */
@Directive({
selector: 'button[appSettingsRadio]',
host: {
role: 'radio',
'[attr.aria-checked]': 'checked()',
'[attr.tabindex]': 'isTabStop() ? 0 : -1',
'(focus)': 'onFocus()',
},
})
export class SettingsRadioDirective implements FocusableOption {
// Not required: a sibling's host binding reads every option's state
// before the later options of the same pass have their inputs.
readonly checked = input(false, { alias: 'appSettingsRadio' });
private readonly group = inject(SettingsRadioGroupDirective);
private readonly element: HTMLButtonElement =
inject<ElementRef<HTMLButtonElement>>(ElementRef).nativeElement;
readonly isTabStop = computed(() => this.group.tabStop() === this);
get disabled(): boolean {
return this.element.disabled;
}
focus(): void {
this.element.focus();
}
onFocus(): void {
this.group.onOptionFocus(this);
}
/** Immediate write of what the `tabindex` binding renders next. */
setTabStop(isTabStop: boolean): void {
this.element.tabIndex = isTabStop ? 0 : -1;
}
}
/**
* A radio group in the settings panel (audio, subtitles, quality, speed,
* aspect, subtitle size and colour). One option is a Tab stop — the checked
* one, or the first when none is — and the arrow keys, Home and End move
* focus between the options with a CDK `FocusKeyManager`. Moving focus does
* not check: every choice is applied to the playing stream at once, and a
* track or quality switch rebuffers, so arrowing through five levels must
* not switch five times. Space or Enter checks the focused option, as in a
* toolbar radio group.
*/
@Directive({
selector: '[appSettingsRadioGroup]',
host: {
role: 'radiogroup',
'(keydown)': 'onKeydown($event)',
'(focusout)': 'onFocusOut($event)',
},
})
export class SettingsRadioGroupDirective {
private readonly host: HTMLElement =
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
private readonly options = contentChildren(SettingsRadioDirective, {
descendants: true,
});
/** The option keyboard focus moved to, while it stays in the group. */
private readonly focused = signal<SettingsRadioDirective | null>(null);
private readonly keyManager = new FocusKeyManager(
this.options,
inject(Injector)
)
.withWrap()
.withVerticalOrientation()
.withHomeAndEnd();
readonly tabStop = computed(() => {
const options = this.options();
const focused = this.focused();
if (focused && options.includes(focused)) {
return focused;
}
return options.find((option) => option.checked()) ?? options[0];
});
constructor() {
inject(DestroyRef).onDestroy(() => this.keyManager.destroy());
}
onOptionFocus(option: SettingsRadioDirective): void {
this.focused.set(option);
this.keyManager.updateActiveItem(option);
this.applyTabStop();
}
onKeydown(event: KeyboardEvent): void {
const rtl = getComputedStyle(this.host).direction === 'rtl';
this.keyManager.withHorizontalOrientation(rtl ? 'rtl' : 'ltr');
this.keyManager.onKeydown(event);
}
/** Leaving the group hands the Tab stop back to the checked option. */
onFocusOut(event: FocusEvent): void {
const next = event.relatedTarget;
if (!(next instanceof Node) || !this.host.contains(next)) {
this.focused.set(null);
this.applyTabStop();
}
}
/**
* The `tabindex` bindings follow on the next change detection, but a
* quick Shift+Tab, Tab can arrive before it and would re-enter the group
* on a stale stop, so focus changes also write it straight away.
*/
private applyTabStop(): void {
const stop = this.tabStop();
for (const option of this.options()) {
option.setTabStop(option === stop);
}
}
}
+1
View File
@@ -211,6 +211,7 @@
"angular-eslint": "22.5.0",
"astro": "7.2.10",
"autoprefixer": "10.5.4",
"axe-core": "4.13.0",
"cookie": "2.0.1",
"cors": "2.8.6",
"drizzle-kit": "0.31.10",
+9
View File
@@ -371,6 +371,9 @@ importers:
autoprefixer:
specifier: 10.5.4
version: 10.5.4(postcss@8.5.26)
axe-core:
specifier: 4.13.0
version: 4.13.0
cookie:
specifier: 2.0.1
version: 2.0.1
@@ -4901,6 +4904,10 @@ packages:
aws4@1.13.2:
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
axe-core@4.13.0:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
axios@1.18.1:
resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
@@ -14512,6 +14519,8 @@ snapshots:
aws4@1.13.2: {}
axe-core@4.13.0: {}
axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))