mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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`),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -626,6 +626,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||
"PLAYBACK_SPEED": "سرعة التشغيل",
|
||||
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "الترجمات: {{subtitles}}",
|
||||
"ASPECT_RATIO": "نسبة العرض إلى الارتفاع",
|
||||
"ASPECT_TOOLTIP": "النسبة: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "افتراضي",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||
"PLAYBACK_SPEED": "سرعة التشغيل",
|
||||
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "الترجمة: {{subtitles}}",
|
||||
"ASPECT_RATIO": "نسبة الأبعاد",
|
||||
"ASPECT_TOOLTIP": "الأبعاد: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "افتراضي",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}",
|
||||
"PLAYBACK_SPEED": "Хуткасць прайгравання",
|
||||
"SPEED_TOOLTIP": "Хуткасць: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Субтытры: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Суадносіны бакоў",
|
||||
"ASPECT_TOOLTIP": "Суадносіны: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Па змаўчанні",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}",
|
||||
"PLAYBACK_SPEED": "Ταχύτητα αναπαραγωγής",
|
||||
"SPEED_TOOLTIP": "Ταχύτητα: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Υπότιτλοι: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Αναλογία εικόνας",
|
||||
"ASPECT_TOOLTIP": "Αναλογία: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Προεπιλογή",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
"PLAYBACK_SPEED": "再生速度",
|
||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||
"ASPECT_RATIO": "アスペクト比",
|
||||
"ASPECT_TOOLTIP": "アスペクト比:{{aspect}}",
|
||||
"ASPECT_DEFAULT": "デフォルト",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "자막 {{index}}",
|
||||
"PLAYBACK_SPEED": "재생 속도",
|
||||
"SPEED_TOOLTIP": "속도: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "자막: {{subtitles}}",
|
||||
"ASPECT_RATIO": "화면 비율",
|
||||
"ASPECT_TOOLTIP": "비율: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "기본",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}",
|
||||
"PLAYBACK_SPEED": "Скорость воспроизведения",
|
||||
"SPEED_TOOLTIP": "Скорость: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Субтитры: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Соотношение сторон",
|
||||
"ASPECT_TOOLTIP": "Соотношение: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "По умолчанию",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
"PLAYBACK_SPEED": "播放速度",
|
||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||
"ASPECT_RATIO": "宽高比",
|
||||
"ASPECT_TOOLTIP": "宽高比:{{aspect}}",
|
||||
"ASPECT_DEFAULT": "默认",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
"PLAYBACK_SPEED": "播放速度",
|
||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||
"ASPECT_RATIO": "長寬比",
|
||||
"ASPECT_TOOLTIP": "長寬比:{{aspect}}",
|
||||
"ASPECT_DEFAULT": "預設",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+3
-1
@@ -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');
|
||||
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+9
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user