mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(player): keyboard focus, contrast and ARIA for controls and settings (#1769)
* fix(player): keyboard focus, contrast and ARIA for controls and settings
Dock and settings-panel icon buttons draw a 2px --pc-text ring on
:focus-visible, and Material's theme-coloured focus layer is off, so
keyboard focus shows on video in the light theme too. A focused selected
subtitle swatch now differs from one that is only selected.
Settings headings read --pc-text-secondary on denser glass
(--pc-glass-bg-dense, 0.86): 4.5:1 or more over mid-grey and white
frames. They wrap (overflow-wrap: anywhere, hyphens: auto), so long
German and Russian headings stay inside the sheet's heading column.
The settings panel is now radio groups only (SettingsRadioGroupDirective
over a CDK FocusKeyManager): one Tab stop per group on the checked option,
arrows, Home and End move focus without applying, and Space/Enter checks.
The dialog and its groups are named by real h2/h3/h4 headings, the
load-file action sits outside the subtitle radio group, the subtitle and
speed chips carry their value in their name ("Subtitles: English"), and
tune has aria-haspopup="dialog".
Adds a web E2E for the keyboard path in both themes with an axe check on
the open panel, and de/ru sheet heading wrapping; axe-core is a new dev
dependency for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(player): arrows check settings radios; subtitle chip reads On
Review follow-up:
- Arrow keys, Home and End now check the settings radio they reach, as a
native radio group does (the directive clicks it, so the template's
handler applies the choice); an option the engine already reports as
checked is not applied again.
- With subtitles on but no track marked selected yet (the engine can
report the switch before the track list), the subtitle chip reads and
announces "On" (new SUBTITLES_ON key, 19 locales) instead of "Off".
- The swatch row has 4px padding on every side, so the outer focus ring
is not clipped at the scroll edge of the panel body.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(player): re-apply a settings radio while a switch is pending
The arrow-key check skipped any option the engine still reported as
checked. Arrowing from audio track A to B and back to A before the engine
confirmed B therefore sent no command for A, and playback ended on B with
focus on A. An arrow move always lands on an option other than the last
one applied, so it now applies unconditionally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d50995c30
commit
adb4889b0f
37 files changed
+1450
-257
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,410 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { pressTab } from './e2e-helpers';
|
||||||
|
import { expect, test } from './fixtures';
|
||||||
|
import {
|
||||||
|
importPlaylist,
|
||||||
|
selectPlayer,
|
||||||
|
serveClip,
|
||||||
|
startClip,
|
||||||
|
} from './player-settings-panel.fixture';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard and assistive-technology access to the shared controls in both
|
||||||
|
* app themes: every dock control and every settings choice shows a focus
|
||||||
|
* ring in the overlay's own colour, each settings radio group is one Tab
|
||||||
|
* stop with arrow-key navigation, the open panel passes axe, its headings
|
||||||
|
* stay readable over bright frames, and long German and Russian headings
|
||||||
|
* wrap inside the compact sheet. ArtPlayer is used because it offers the
|
||||||
|
* fullest panel (subtitle file, size and colour, speed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: 'block' });
|
||||||
|
|
||||||
|
/** `--pc-text`, the overlay's text colour, as computed styles report it. */
|
||||||
|
const RING_COLOR = 'rgb(231, 236, 243)';
|
||||||
|
const AXE_SOURCE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
|
||||||
|
|
||||||
|
interface FocusStop {
|
||||||
|
label: string;
|
||||||
|
role: string | null;
|
||||||
|
group: string | null;
|
||||||
|
checked: string | null;
|
||||||
|
iconButton: boolean;
|
||||||
|
outline: string;
|
||||||
|
outlineColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Describes the focused element and the ring it draws. */
|
||||||
|
function focusStop(page: Page): Promise<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 and apply the option they reach. ----------
|
||||||
|
const speedGroup = panel.locator(
|
||||||
|
'[data-test-id="player-settings-speed"] [role="radiogroup"]'
|
||||||
|
);
|
||||||
|
const rate = () =>
|
||||||
|
video.evaluate((el: HTMLVideoElement) => el.playbackRate);
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
const moved = await focusStop(page);
|
||||||
|
expect(moved?.role).toBe('radio');
|
||||||
|
await expect.poll(rate).toBe(Number.parseFloat(moved?.label ?? ''));
|
||||||
|
await expect(
|
||||||
|
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||||||
|
).toHaveText(moved?.label ?? '');
|
||||||
|
await page.keyboard.press('Home');
|
||||||
|
expect((await focusStop(page))?.label).toBe('0.5×');
|
||||||
|
await expect.poll(rate).toBe(0.5);
|
||||||
|
await expect(
|
||||||
|
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||||||
|
).toHaveText('0.5×');
|
||||||
|
// The ends wrap, as in a native radio group.
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
expect((await focusStop(page))?.label).toBe('2×');
|
||||||
|
await expect.poll(rate).toBe(2);
|
||||||
|
await expect(speedGroup.locator('[tabindex="0"]')).toHaveText('2×');
|
||||||
|
// Leaving and re-entering lands on the checked option — the one the
|
||||||
|
// engine reports, so wait for it to confirm the switch first.
|
||||||
|
await expect(
|
||||||
|
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||||||
|
).toHaveText('2×');
|
||||||
|
await pressTab(page, browserName, 'backward');
|
||||||
|
await pressTab(page, browserName);
|
||||||
|
expect((await focusStop(page))?.label).toBe('2×');
|
||||||
|
|
||||||
|
// --- A focused selected swatch differs from a selected one. -------
|
||||||
|
await tabUntil(page, browserName, {
|
||||||
|
target: '.player-settings__swatches',
|
||||||
|
direction: 'backward',
|
||||||
|
max: 5,
|
||||||
|
});
|
||||||
|
const focusedSwatch = panel.locator('.player-settings__swatch:focus');
|
||||||
|
await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await expect(focusedSwatch).toHaveCSS('outline-style', 'solid');
|
||||||
|
// The arrow checks the next swatch, which takes focus and the ring.
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await expect(focusedSwatch).toHaveCSS('outline-style', 'solid');
|
||||||
|
// Tab moves on to the speed group: the checked swatch keeps its
|
||||||
|
// selected border but loses the ring.
|
||||||
|
await pressTab(page, browserName);
|
||||||
|
expect(
|
||||||
|
await focusIsInside(page, '[data-test-id="player-settings-speed"]')
|
||||||
|
).toBe(true);
|
||||||
|
const selectedOnly = panel.locator(
|
||||||
|
'.player-settings__swatch[aria-checked="true"]'
|
||||||
|
);
|
||||||
|
await expect(selectedOnly).toHaveCount(1);
|
||||||
|
await expect(selectedOnly).not.toBeFocused();
|
||||||
|
await expect(selectedOnly).toHaveCSS('outline-style', 'none');
|
||||||
|
await expect(selectedOnly).toHaveCSS(
|
||||||
|
'border-top-color',
|
||||||
|
'rgb(255, 255, 255)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Headings, axe and closing. ----------------------------------
|
||||||
|
const contrast = await headingContrast(panel);
|
||||||
|
expect(contrast.length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
contrast.filter(
|
||||||
|
(heading) => heading.midGrey < 4.5 || heading.white < 4.5
|
||||||
|
)
|
||||||
|
).toEqual([]);
|
||||||
|
await expectNoAxeViolations(panel);
|
||||||
|
await page.screenshot({
|
||||||
|
path: test.info().outputPath(`${theme}-panel-focus.png`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus is on a speed radio, which has no tooltip. Material spends
|
||||||
|
// an Escape on any tooltip still showing (the swatch's fades out),
|
||||||
|
// so wait for it to go before closing.
|
||||||
|
await expect(page.locator('.mat-mdc-tooltip')).toHaveCount(0);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(panel).toHaveCount(0);
|
||||||
|
await expect(tune).toBeFocused();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [locale, speedHeading] of [
|
||||||
|
['de', 'Wiedergabegeschwindigkeit'],
|
||||||
|
['ru', 'Скорость воспроизведения'],
|
||||||
|
] as const) {
|
||||||
|
test(`@web @playback settings sheet headings wrap in the ${locale} locale`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await page.setViewportSize({ width: 900, height: 700 });
|
||||||
|
await serveClip(page);
|
||||||
|
await selectPlayer(page, 'ArtPlayer');
|
||||||
|
const playlistUrl = await importPlaylist(page);
|
||||||
|
|
||||||
|
await page.goto('/workspace/settings/general');
|
||||||
|
await page.locator('[data-test-id="select-language"]').click();
|
||||||
|
await page.locator(`mat-option[data-test-id="${locale}"]`).click();
|
||||||
|
const save = page.locator('[data-test-id="save-settings"]');
|
||||||
|
await save.click();
|
||||||
|
await expect(save).toBeHidden();
|
||||||
|
|
||||||
|
await page.goto(playlistUrl);
|
||||||
|
const { controls } = await startClip(page);
|
||||||
|
await controls
|
||||||
|
.locator('[data-test-id="player-controls-settings-button"]')
|
||||||
|
.click();
|
||||||
|
const sheet = controls.locator(
|
||||||
|
'[data-test-id="player-controls-settings-panel"]'
|
||||||
|
);
|
||||||
|
await expect(sheet).toHaveClass(/player-controls__settings--sheet/);
|
||||||
|
await expect(
|
||||||
|
sheet.locator('[data-test-id="player-settings-speed"] h3')
|
||||||
|
).toHaveText(speedHeading);
|
||||||
|
|
||||||
|
for (const theme of ['light', 'dark'] as const) {
|
||||||
|
await applyTheme(page, theme);
|
||||||
|
const headings = await sheet.evaluate((host) =>
|
||||||
|
Array.from(host.querySelectorAll('h3, h4'), (heading) => {
|
||||||
|
const box = heading.getBoundingClientRect();
|
||||||
|
const control = heading.nextElementSibling;
|
||||||
|
const style = getComputedStyle(heading);
|
||||||
|
return {
|
||||||
|
text: heading.textContent?.trim(),
|
||||||
|
overflow: heading.scrollWidth - heading.clientWidth,
|
||||||
|
// A group heading sits in the sheet's left column,
|
||||||
|
// beside its control.
|
||||||
|
intoControl:
|
||||||
|
heading.tagName === 'H3' && control
|
||||||
|
? box.right -
|
||||||
|
control.getBoundingClientRect().left
|
||||||
|
: 0,
|
||||||
|
lines: Math.round(
|
||||||
|
(box.height -
|
||||||
|
Number.parseFloat(style.paddingTop) -
|
||||||
|
Number.parseFloat(style.paddingBottom)) /
|
||||||
|
Number.parseFloat(style.lineHeight)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const heading of headings) {
|
||||||
|
expect(heading.overflow, heading.text).toBeLessThanOrEqual(0);
|
||||||
|
expect(heading.intoControl, heading.text).toBeLessThanOrEqual(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
headings.find((heading) => heading.text === speedHeading)?.lines
|
||||||
|
).toBeGreaterThan(1);
|
||||||
|
await expectNoAxeViolations(sheet);
|
||||||
|
}
|
||||||
|
await page.screenshot({
|
||||||
|
path: test.info().outputPath(`${locale}-sheet.png`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 { 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
|
* 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.
|
* 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' });
|
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 ({
|
test('@web @playback settings panel opens from the speed chip and applies in place', async ({
|
||||||
page,
|
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.
|
// Chips and the side panel need a player of at least 960px.
|
||||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||||
await serveClip(page);
|
await serveClip(page);
|
||||||
await selectHtml5Player(page);
|
await selectPlayer(page);
|
||||||
await importPlaylist(page);
|
await importPlaylist(page);
|
||||||
const { video, controls } = await startClip(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);
|
test.setTimeout(90_000);
|
||||||
await page.setViewportSize({ width: 900, height: 700 });
|
await page.setViewportSize({ width: 900, height: 700 });
|
||||||
await serveClip(page);
|
await serveClip(page);
|
||||||
await selectHtml5Player(page);
|
await selectPlayer(page);
|
||||||
await importPlaylist(page);
|
await importPlaylist(page);
|
||||||
const { view, controls } = await startClip(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 };
|
||||||
|
}
|
||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "مسارات الصوت",
|
"AUDIO_TRACKS": "مسارات الصوت",
|
||||||
"SUBTITLES": "الترجمات",
|
"SUBTITLES": "الترجمات",
|
||||||
"SUBTITLES_OFF": "إيقاف",
|
"SUBTITLES_OFF": "إيقاف",
|
||||||
|
"SUBTITLES_ON": "تشغيل",
|
||||||
"QUALITY": "الجودة",
|
"QUALITY": "الجودة",
|
||||||
"QUALITY_AUTO": "تلقائي",
|
"QUALITY_AUTO": "تلقائي",
|
||||||
"TRACK_DEFAULT": "افتراضي",
|
"TRACK_DEFAULT": "افتراضي",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||||
"PLAYBACK_SPEED": "سرعة التشغيل",
|
"PLAYBACK_SPEED": "سرعة التشغيل",
|
||||||
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "الترجمات: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "نسبة العرض إلى الارتفاع",
|
"ASPECT_RATIO": "نسبة العرض إلى الارتفاع",
|
||||||
"ASPECT_TOOLTIP": "النسبة: {{aspect}}",
|
"ASPECT_TOOLTIP": "النسبة: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "افتراضي",
|
"ASPECT_DEFAULT": "افتراضي",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "مسارات الصوت",
|
"AUDIO_TRACKS": "مسارات الصوت",
|
||||||
"SUBTITLES": "الترجمة",
|
"SUBTITLES": "الترجمة",
|
||||||
"SUBTITLES_OFF": "معطلة",
|
"SUBTITLES_OFF": "معطلة",
|
||||||
|
"SUBTITLES_ON": "مفعلة",
|
||||||
"QUALITY": "الجودة",
|
"QUALITY": "الجودة",
|
||||||
"QUALITY_AUTO": "تلقائي",
|
"QUALITY_AUTO": "تلقائي",
|
||||||
"TRACK_DEFAULT": "افتراضي",
|
"TRACK_DEFAULT": "افتراضي",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||||
"PLAYBACK_SPEED": "سرعة التشغيل",
|
"PLAYBACK_SPEED": "سرعة التشغيل",
|
||||||
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "الترجمة: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "نسبة الأبعاد",
|
"ASPECT_RATIO": "نسبة الأبعاد",
|
||||||
"ASPECT_TOOLTIP": "الأبعاد: {{aspect}}",
|
"ASPECT_TOOLTIP": "الأبعاد: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "افتراضي",
|
"ASPECT_DEFAULT": "افتراضي",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Аўдыядарожкі",
|
"AUDIO_TRACKS": "Аўдыядарожкі",
|
||||||
"SUBTITLES": "Субтытры",
|
"SUBTITLES": "Субтытры",
|
||||||
"SUBTITLES_OFF": "Выкл.",
|
"SUBTITLES_OFF": "Выкл.",
|
||||||
|
"SUBTITLES_ON": "Укл.",
|
||||||
"QUALITY": "Якасць",
|
"QUALITY": "Якасць",
|
||||||
"QUALITY_AUTO": "Аўта",
|
"QUALITY_AUTO": "Аўта",
|
||||||
"TRACK_DEFAULT": "Па змаўчанні",
|
"TRACK_DEFAULT": "Па змаўчанні",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}",
|
||||||
"PLAYBACK_SPEED": "Хуткасць прайгравання",
|
"PLAYBACK_SPEED": "Хуткасць прайгравання",
|
||||||
"SPEED_TOOLTIP": "Хуткасць: {{speed}}",
|
"SPEED_TOOLTIP": "Хуткасць: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Субтытры: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Суадносіны бакоў",
|
"ASPECT_RATIO": "Суадносіны бакоў",
|
||||||
"ASPECT_TOOLTIP": "Суадносіны: {{aspect}}",
|
"ASPECT_TOOLTIP": "Суадносіны: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Па змаўчанні",
|
"ASPECT_DEFAULT": "Па змаўчанні",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Tonspuren",
|
"AUDIO_TRACKS": "Tonspuren",
|
||||||
"SUBTITLES": "Untertitel",
|
"SUBTITLES": "Untertitel",
|
||||||
"SUBTITLES_OFF": "Aus",
|
"SUBTITLES_OFF": "Aus",
|
||||||
|
"SUBTITLES_ON": "An",
|
||||||
"QUALITY": "Qualität",
|
"QUALITY": "Qualität",
|
||||||
"QUALITY_AUTO": "Automatisch",
|
"QUALITY_AUTO": "Automatisch",
|
||||||
"TRACK_DEFAULT": "Standard",
|
"TRACK_DEFAULT": "Standard",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}",
|
||||||
"PLAYBACK_SPEED": "Wiedergabegeschwindigkeit",
|
"PLAYBACK_SPEED": "Wiedergabegeschwindigkeit",
|
||||||
"SPEED_TOOLTIP": "Geschwindigkeit: {{speed}}",
|
"SPEED_TOOLTIP": "Geschwindigkeit: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Untertitel: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Seitenverhältnis",
|
"ASPECT_RATIO": "Seitenverhältnis",
|
||||||
"ASPECT_TOOLTIP": "Seitenverhältnis: {{aspect}}",
|
"ASPECT_TOOLTIP": "Seitenverhältnis: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Standard",
|
"ASPECT_DEFAULT": "Standard",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Κομμάτια ήχου",
|
"AUDIO_TRACKS": "Κομμάτια ήχου",
|
||||||
"SUBTITLES": "Υπότιτλοι",
|
"SUBTITLES": "Υπότιτλοι",
|
||||||
"SUBTITLES_OFF": "Ανενεργοί",
|
"SUBTITLES_OFF": "Ανενεργοί",
|
||||||
|
"SUBTITLES_ON": "Ενεργοί",
|
||||||
"QUALITY": "Ποιότητα",
|
"QUALITY": "Ποιότητα",
|
||||||
"QUALITY_AUTO": "Αυτόματη",
|
"QUALITY_AUTO": "Αυτόματη",
|
||||||
"TRACK_DEFAULT": "Προεπιλογή",
|
"TRACK_DEFAULT": "Προεπιλογή",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}",
|
||||||
"PLAYBACK_SPEED": "Ταχύτητα αναπαραγωγής",
|
"PLAYBACK_SPEED": "Ταχύτητα αναπαραγωγής",
|
||||||
"SPEED_TOOLTIP": "Ταχύτητα: {{speed}}",
|
"SPEED_TOOLTIP": "Ταχύτητα: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Υπότιτλοι: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Αναλογία εικόνας",
|
"ASPECT_RATIO": "Αναλογία εικόνας",
|
||||||
"ASPECT_TOOLTIP": "Αναλογία: {{aspect}}",
|
"ASPECT_TOOLTIP": "Αναλογία: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Προεπιλογή",
|
"ASPECT_DEFAULT": "Προεπιλογή",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Audio tracks",
|
"AUDIO_TRACKS": "Audio tracks",
|
||||||
"SUBTITLES": "Subtitles",
|
"SUBTITLES": "Subtitles",
|
||||||
"SUBTITLES_OFF": "Off",
|
"SUBTITLES_OFF": "Off",
|
||||||
|
"SUBTITLES_ON": "On",
|
||||||
"QUALITY": "Quality",
|
"QUALITY": "Quality",
|
||||||
"QUALITY_AUTO": "Auto",
|
"QUALITY_AUTO": "Auto",
|
||||||
"TRACK_DEFAULT": "Default",
|
"TRACK_DEFAULT": "Default",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}",
|
||||||
"PLAYBACK_SPEED": "Playback speed",
|
"PLAYBACK_SPEED": "Playback speed",
|
||||||
"SPEED_TOOLTIP": "Speed: {{speed}}",
|
"SPEED_TOOLTIP": "Speed: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Subtitles: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Aspect ratio",
|
"ASPECT_RATIO": "Aspect ratio",
|
||||||
"ASPECT_TOOLTIP": "Aspect: {{aspect}}",
|
"ASPECT_TOOLTIP": "Aspect: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Default",
|
"ASPECT_DEFAULT": "Default",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Pistas de audio",
|
"AUDIO_TRACKS": "Pistas de audio",
|
||||||
"SUBTITLES": "Subtítulos",
|
"SUBTITLES": "Subtítulos",
|
||||||
"SUBTITLES_OFF": "Desactivados",
|
"SUBTITLES_OFF": "Desactivados",
|
||||||
|
"SUBTITLES_ON": "Activados",
|
||||||
"QUALITY": "Calidad",
|
"QUALITY": "Calidad",
|
||||||
"QUALITY_AUTO": "Automática",
|
"QUALITY_AUTO": "Automática",
|
||||||
"TRACK_DEFAULT": "Predeterminada",
|
"TRACK_DEFAULT": "Predeterminada",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}",
|
||||||
"PLAYBACK_SPEED": "Velocidad de reproducción",
|
"PLAYBACK_SPEED": "Velocidad de reproducción",
|
||||||
"SPEED_TOOLTIP": "Velocidad: {{speed}}",
|
"SPEED_TOOLTIP": "Velocidad: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Subtítulos: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Relación de aspecto",
|
"ASPECT_RATIO": "Relación de aspecto",
|
||||||
"ASPECT_TOOLTIP": "Aspecto: {{aspect}}",
|
"ASPECT_TOOLTIP": "Aspecto: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Predeterminada",
|
"ASPECT_DEFAULT": "Predeterminada",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Pistes audio",
|
"AUDIO_TRACKS": "Pistes audio",
|
||||||
"SUBTITLES": "Sous-titres",
|
"SUBTITLES": "Sous-titres",
|
||||||
"SUBTITLES_OFF": "Désactivés",
|
"SUBTITLES_OFF": "Désactivés",
|
||||||
|
"SUBTITLES_ON": "Activés",
|
||||||
"QUALITY": "Qualité",
|
"QUALITY": "Qualité",
|
||||||
"QUALITY_AUTO": "Auto",
|
"QUALITY_AUTO": "Auto",
|
||||||
"TRACK_DEFAULT": "Par défaut",
|
"TRACK_DEFAULT": "Par défaut",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}",
|
||||||
"PLAYBACK_SPEED": "Vitesse de lecture",
|
"PLAYBACK_SPEED": "Vitesse de lecture",
|
||||||
"SPEED_TOOLTIP": "Vitesse : {{speed}}",
|
"SPEED_TOOLTIP": "Vitesse : {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Sous-titres : {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Format d'image",
|
"ASPECT_RATIO": "Format d'image",
|
||||||
"ASPECT_TOOLTIP": "Format : {{aspect}}",
|
"ASPECT_TOOLTIP": "Format : {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Par défaut",
|
"ASPECT_DEFAULT": "Par défaut",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Hangsávok",
|
"AUDIO_TRACKS": "Hangsávok",
|
||||||
"SUBTITLES": "Feliratok",
|
"SUBTITLES": "Feliratok",
|
||||||
"SUBTITLES_OFF": "Kikapcsolva",
|
"SUBTITLES_OFF": "Kikapcsolva",
|
||||||
|
"SUBTITLES_ON": "Bekapcsolva",
|
||||||
"QUALITY": "Minőség",
|
"QUALITY": "Minőség",
|
||||||
"QUALITY_AUTO": "Automatikus",
|
"QUALITY_AUTO": "Automatikus",
|
||||||
"TRACK_DEFAULT": "Alapértelmezett",
|
"TRACK_DEFAULT": "Alapértelmezett",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "{{index}}. feliratsáv",
|
"SUBTITLE_TRACK_FALLBACK": "{{index}}. feliratsáv",
|
||||||
"PLAYBACK_SPEED": "Lejátszási sebesség",
|
"PLAYBACK_SPEED": "Lejátszási sebesség",
|
||||||
"SPEED_TOOLTIP": "Sebesség: {{speed}}",
|
"SPEED_TOOLTIP": "Sebesség: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Feliratok: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Képarány",
|
"ASPECT_RATIO": "Képarány",
|
||||||
"ASPECT_TOOLTIP": "Képarány: {{aspect}}",
|
"ASPECT_TOOLTIP": "Képarány: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Alapértelmezett",
|
"ASPECT_DEFAULT": "Alapértelmezett",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Tracce audio",
|
"AUDIO_TRACKS": "Tracce audio",
|
||||||
"SUBTITLES": "Sottotitoli",
|
"SUBTITLES": "Sottotitoli",
|
||||||
"SUBTITLES_OFF": "Disattivati",
|
"SUBTITLES_OFF": "Disattivati",
|
||||||
|
"SUBTITLES_ON": "Attivati",
|
||||||
"QUALITY": "Qualità",
|
"QUALITY": "Qualità",
|
||||||
"QUALITY_AUTO": "Automatica",
|
"QUALITY_AUTO": "Automatica",
|
||||||
"TRACK_DEFAULT": "Predefinita",
|
"TRACK_DEFAULT": "Predefinita",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}",
|
||||||
"PLAYBACK_SPEED": "Velocità di riproduzione",
|
"PLAYBACK_SPEED": "Velocità di riproduzione",
|
||||||
"SPEED_TOOLTIP": "Velocità: {{speed}}",
|
"SPEED_TOOLTIP": "Velocità: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Sottotitoli: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Proporzioni",
|
"ASPECT_RATIO": "Proporzioni",
|
||||||
"ASPECT_TOOLTIP": "Proporzioni: {{aspect}}",
|
"ASPECT_TOOLTIP": "Proporzioni: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Predefinite",
|
"ASPECT_DEFAULT": "Predefinite",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "音声トラック",
|
"AUDIO_TRACKS": "音声トラック",
|
||||||
"SUBTITLES": "字幕",
|
"SUBTITLES": "字幕",
|
||||||
"SUBTITLES_OFF": "オフ",
|
"SUBTITLES_OFF": "オフ",
|
||||||
|
"SUBTITLES_ON": "オン",
|
||||||
"QUALITY": "画質",
|
"QUALITY": "画質",
|
||||||
"QUALITY_AUTO": "自動",
|
"QUALITY_AUTO": "自動",
|
||||||
"TRACK_DEFAULT": "デフォルト",
|
"TRACK_DEFAULT": "デフォルト",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||||
"PLAYBACK_SPEED": "再生速度",
|
"PLAYBACK_SPEED": "再生速度",
|
||||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||||
"ASPECT_RATIO": "アスペクト比",
|
"ASPECT_RATIO": "アスペクト比",
|
||||||
"ASPECT_TOOLTIP": "アスペクト比:{{aspect}}",
|
"ASPECT_TOOLTIP": "アスペクト比:{{aspect}}",
|
||||||
"ASPECT_DEFAULT": "デフォルト",
|
"ASPECT_DEFAULT": "デフォルト",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "오디오 트랙",
|
"AUDIO_TRACKS": "오디오 트랙",
|
||||||
"SUBTITLES": "자막",
|
"SUBTITLES": "자막",
|
||||||
"SUBTITLES_OFF": "끄기",
|
"SUBTITLES_OFF": "끄기",
|
||||||
|
"SUBTITLES_ON": "켜기",
|
||||||
"QUALITY": "화질",
|
"QUALITY": "화질",
|
||||||
"QUALITY_AUTO": "자동",
|
"QUALITY_AUTO": "자동",
|
||||||
"TRACK_DEFAULT": "기본",
|
"TRACK_DEFAULT": "기본",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "자막 {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "자막 {{index}}",
|
||||||
"PLAYBACK_SPEED": "재생 속도",
|
"PLAYBACK_SPEED": "재생 속도",
|
||||||
"SPEED_TOOLTIP": "속도: {{speed}}",
|
"SPEED_TOOLTIP": "속도: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "자막: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "화면 비율",
|
"ASPECT_RATIO": "화면 비율",
|
||||||
"ASPECT_TOOLTIP": "비율: {{aspect}}",
|
"ASPECT_TOOLTIP": "비율: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "기본",
|
"ASPECT_DEFAULT": "기본",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Audiosporen",
|
"AUDIO_TRACKS": "Audiosporen",
|
||||||
"SUBTITLES": "Ondertiteling",
|
"SUBTITLES": "Ondertiteling",
|
||||||
"SUBTITLES_OFF": "Uit",
|
"SUBTITLES_OFF": "Uit",
|
||||||
|
"SUBTITLES_ON": "Aan",
|
||||||
"QUALITY": "Kwaliteit",
|
"QUALITY": "Kwaliteit",
|
||||||
"QUALITY_AUTO": "Automatisch",
|
"QUALITY_AUTO": "Automatisch",
|
||||||
"TRACK_DEFAULT": "Standaard",
|
"TRACK_DEFAULT": "Standaard",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}",
|
||||||
"PLAYBACK_SPEED": "Afspeelsnelheid",
|
"PLAYBACK_SPEED": "Afspeelsnelheid",
|
||||||
"SPEED_TOOLTIP": "Snelheid: {{speed}}",
|
"SPEED_TOOLTIP": "Snelheid: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Ondertiteling: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Beeldverhouding",
|
"ASPECT_RATIO": "Beeldverhouding",
|
||||||
"ASPECT_TOOLTIP": "Beeldverhouding: {{aspect}}",
|
"ASPECT_TOOLTIP": "Beeldverhouding: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Standaard",
|
"ASPECT_DEFAULT": "Standaard",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Ścieżki audio",
|
"AUDIO_TRACKS": "Ścieżki audio",
|
||||||
"SUBTITLES": "Napisy",
|
"SUBTITLES": "Napisy",
|
||||||
"SUBTITLES_OFF": "Wyłączone",
|
"SUBTITLES_OFF": "Wyłączone",
|
||||||
|
"SUBTITLES_ON": "Włączone",
|
||||||
"QUALITY": "Jakość",
|
"QUALITY": "Jakość",
|
||||||
"QUALITY_AUTO": "Automatycznie",
|
"QUALITY_AUTO": "Automatycznie",
|
||||||
"TRACK_DEFAULT": "Domyślna",
|
"TRACK_DEFAULT": "Domyślna",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}",
|
||||||
"PLAYBACK_SPEED": "Prędkość odtwarzania",
|
"PLAYBACK_SPEED": "Prędkość odtwarzania",
|
||||||
"SPEED_TOOLTIP": "Prędkość: {{speed}}",
|
"SPEED_TOOLTIP": "Prędkość: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Napisy: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Proporcje obrazu",
|
"ASPECT_RATIO": "Proporcje obrazu",
|
||||||
"ASPECT_TOOLTIP": "Proporcje: {{aspect}}",
|
"ASPECT_TOOLTIP": "Proporcje: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Domyślne",
|
"ASPECT_DEFAULT": "Domyślne",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Faixas de áudio",
|
"AUDIO_TRACKS": "Faixas de áudio",
|
||||||
"SUBTITLES": "Legendas",
|
"SUBTITLES": "Legendas",
|
||||||
"SUBTITLES_OFF": "Desativadas",
|
"SUBTITLES_OFF": "Desativadas",
|
||||||
|
"SUBTITLES_ON": "Ativadas",
|
||||||
"QUALITY": "Qualidade",
|
"QUALITY": "Qualidade",
|
||||||
"QUALITY_AUTO": "Automática",
|
"QUALITY_AUTO": "Automática",
|
||||||
"TRACK_DEFAULT": "Padrão",
|
"TRACK_DEFAULT": "Padrão",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}",
|
||||||
"PLAYBACK_SPEED": "Velocidade de reprodução",
|
"PLAYBACK_SPEED": "Velocidade de reprodução",
|
||||||
"SPEED_TOOLTIP": "Velocidade: {{speed}}",
|
"SPEED_TOOLTIP": "Velocidade: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Legendas: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Proporção da imagem",
|
"ASPECT_RATIO": "Proporção da imagem",
|
||||||
"ASPECT_TOOLTIP": "Proporção: {{aspect}}",
|
"ASPECT_TOOLTIP": "Proporção: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Padrão",
|
"ASPECT_DEFAULT": "Padrão",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Аудиодорожки",
|
"AUDIO_TRACKS": "Аудиодорожки",
|
||||||
"SUBTITLES": "Субтитры",
|
"SUBTITLES": "Субтитры",
|
||||||
"SUBTITLES_OFF": "Выкл.",
|
"SUBTITLES_OFF": "Выкл.",
|
||||||
|
"SUBTITLES_ON": "Вкл.",
|
||||||
"QUALITY": "Качество",
|
"QUALITY": "Качество",
|
||||||
"QUALITY_AUTO": "Авто",
|
"QUALITY_AUTO": "Авто",
|
||||||
"TRACK_DEFAULT": "По умолчанию",
|
"TRACK_DEFAULT": "По умолчанию",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}",
|
||||||
"PLAYBACK_SPEED": "Скорость воспроизведения",
|
"PLAYBACK_SPEED": "Скорость воспроизведения",
|
||||||
"SPEED_TOOLTIP": "Скорость: {{speed}}",
|
"SPEED_TOOLTIP": "Скорость: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Субтитры: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "Соотношение сторон",
|
"ASPECT_RATIO": "Соотношение сторон",
|
||||||
"ASPECT_TOOLTIP": "Соотношение: {{aspect}}",
|
"ASPECT_TOOLTIP": "Соотношение: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "По умолчанию",
|
"ASPECT_DEFAULT": "По умолчанию",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "Ses parçaları",
|
"AUDIO_TRACKS": "Ses parçaları",
|
||||||
"SUBTITLES": "Altyazılar",
|
"SUBTITLES": "Altyazılar",
|
||||||
"SUBTITLES_OFF": "Kapalı",
|
"SUBTITLES_OFF": "Kapalı",
|
||||||
|
"SUBTITLES_ON": "Açık",
|
||||||
"QUALITY": "Kalite",
|
"QUALITY": "Kalite",
|
||||||
"QUALITY_AUTO": "Otomatik",
|
"QUALITY_AUTO": "Otomatik",
|
||||||
"TRACK_DEFAULT": "Varsayılan",
|
"TRACK_DEFAULT": "Varsayılan",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}",
|
||||||
"PLAYBACK_SPEED": "Oynatma hızı",
|
"PLAYBACK_SPEED": "Oynatma hızı",
|
||||||
"SPEED_TOOLTIP": "Hız: {{speed}}",
|
"SPEED_TOOLTIP": "Hız: {{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "Altyazılar: {{subtitles}}",
|
||||||
"ASPECT_RATIO": "En boy oranı",
|
"ASPECT_RATIO": "En boy oranı",
|
||||||
"ASPECT_TOOLTIP": "En boy oranı: {{aspect}}",
|
"ASPECT_TOOLTIP": "En boy oranı: {{aspect}}",
|
||||||
"ASPECT_DEFAULT": "Varsayılan",
|
"ASPECT_DEFAULT": "Varsayılan",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "音轨",
|
"AUDIO_TRACKS": "音轨",
|
||||||
"SUBTITLES": "字幕",
|
"SUBTITLES": "字幕",
|
||||||
"SUBTITLES_OFF": "关闭",
|
"SUBTITLES_OFF": "关闭",
|
||||||
|
"SUBTITLES_ON": "开启",
|
||||||
"QUALITY": "画质",
|
"QUALITY": "画质",
|
||||||
"QUALITY_AUTO": "自动",
|
"QUALITY_AUTO": "自动",
|
||||||
"TRACK_DEFAULT": "默认",
|
"TRACK_DEFAULT": "默认",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||||
"PLAYBACK_SPEED": "播放速度",
|
"PLAYBACK_SPEED": "播放速度",
|
||||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||||
"ASPECT_RATIO": "宽高比",
|
"ASPECT_RATIO": "宽高比",
|
||||||
"ASPECT_TOOLTIP": "宽高比:{{aspect}}",
|
"ASPECT_TOOLTIP": "宽高比:{{aspect}}",
|
||||||
"ASPECT_DEFAULT": "默认",
|
"ASPECT_DEFAULT": "默认",
|
||||||
|
|||||||
@@ -620,6 +620,7 @@
|
|||||||
"AUDIO_TRACKS": "音軌",
|
"AUDIO_TRACKS": "音軌",
|
||||||
"SUBTITLES": "字幕",
|
"SUBTITLES": "字幕",
|
||||||
"SUBTITLES_OFF": "關閉",
|
"SUBTITLES_OFF": "關閉",
|
||||||
|
"SUBTITLES_ON": "開啟",
|
||||||
"QUALITY": "畫質",
|
"QUALITY": "畫質",
|
||||||
"QUALITY_AUTO": "自動",
|
"QUALITY_AUTO": "自動",
|
||||||
"TRACK_DEFAULT": "預設",
|
"TRACK_DEFAULT": "預設",
|
||||||
@@ -627,6 +628,7 @@
|
|||||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||||
"PLAYBACK_SPEED": "播放速度",
|
"PLAYBACK_SPEED": "播放速度",
|
||||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||||
|
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||||
"ASPECT_RATIO": "長寬比",
|
"ASPECT_RATIO": "長寬比",
|
||||||
"ASPECT_TOOLTIP": "長寬比:{{aspect}}",
|
"ASPECT_TOOLTIP": "長寬比:{{aspect}}",
|
||||||
"ASPECT_DEFAULT": "預設",
|
"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
|
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
|
dock (accent blue, cyan, violet, the `--pc-live` / `--pc-danger` reds and a
|
||||||
light text ramp), never the app theme. The overlay styles in
|
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
|
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,
|
`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
|
- `ControlsSettings` — the settings panel's groups, on/modified state and
|
||||||
open/close transitions (`controls-settings-groups.ts` holds the pure
|
open/close transitions (`controls-settings-groups.ts` holds the pure
|
||||||
group-availability rule);
|
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
|
- `ControlsUpNext` and `app-player-up-next-card` — the "Up next" card's
|
||||||
gate and presentation; and
|
gate and presentation; and
|
||||||
- `controls-view-model.ts` — derived display state.
|
- `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.
|
measured below 3:1 against the lighter fills.
|
||||||
- **Icon buttons** are 40px with a 12px radius (32px / 9px compact) through
|
- **Icon buttons** are 40px with a 12px radius (32px / 9px compact) through
|
||||||
Material's `--mat-icon-button-*` tokens; their hover is a flat
|
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
|
### Timeline segments
|
||||||
|
|
||||||
@@ -458,7 +463,10 @@ the panel the moment the last group disappears.
|
|||||||
|
|
||||||
- **Roomy wide dock** (≥ 960px): two **value chips** precede `tune` — subtitles
|
- **Roomy wide dock** (≥ 960px): two **value chips** precede `tune` — subtitles
|
||||||
(`closed_caption` + the selected track's label, or "Off") and speed
|
(`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**
|
panel-only. A chip click opens the panel **focused on its group**
|
||||||
(`settingsFocus`; the group scrolls into view and wears a brief ring);
|
(`settingsFocus`; the group scrolls into view and wears a brief ring);
|
||||||
right-click or long-press on the subtitle chip toggles subtitles without
|
right-click or long-press on the subtitle chip toggles subtitles without
|
||||||
@@ -476,12 +484,34 @@ the panel the moment the last group disappears.
|
|||||||
picture and recording stay in the compact dock — the mock shows only
|
picture and recording stay in the compact dock — the mock shows only
|
||||||
`tune` + fullscreen there, but those two are engine features a viewer
|
`tune` + fullscreen there, but those two are engine features a viewer
|
||||||
needs without opening anything.
|
needs without opening anything.
|
||||||
- **Inside**: list groups (audio, subtitles, quality) use `menuitemradio`
|
- **Inside**: every choice is a `radio` in a `radiogroup`
|
||||||
rows with a check mark and a cyan selection; segmented groups (speed,
|
(`SettingsRadioGroupDirective` / `SettingsRadioDirective`). List groups
|
||||||
aspect) use `radio` items with a violet selection, and a selected default
|
(audio, subtitles, quality) are rows with a check mark and a cyan
|
||||||
(`1×`, the first aspect preset) stays neutral. The subtitle group carries
|
selection; segmented groups (speed, aspect, subtitle size) have a violet
|
||||||
the load-file action and the delay / size / color sections that the
|
selection, and a selected default (`1×`, the first aspect preset) stays
|
||||||
popover used to hold (same `player-controls-load-subtitle`,
|
neutral. Each group is one Tab stop — the checked option as the engine
|
||||||
|
reports it, else the first — and arrows, Home and End move focus with a
|
||||||
|
CDK `FocusKeyManager` (wrapping; the horizontal arrows follow `direction`)
|
||||||
|
and check the option they reach, as a native radio group does: the
|
||||||
|
directive clicks it, so the template's handler applies the choice, even
|
||||||
|
onto an option the engine still reports as checked: returning to it must
|
||||||
|
cancel a switch that is still pending. Focus
|
||||||
|
changes also write the roving `tabindex` immediately, because a quick
|
||||||
|
Shift+Tab, Tab can arrive before change detection updates the bindings. The dialog is named by its `h2` title (`aria-labelledby`), each
|
||||||
|
radio group by its `h3` group heading or `h4` subheading, and the delay
|
||||||
|
buttons form a labelled `group`. Headings read `--pc-text-secondary` on
|
||||||
|
`--pc-glass-bg-dense` (`rgba(12,16,23,.86)`), 4.5:1 or more even over a
|
||||||
|
white frame. They wrap with `overflow-wrap: anywhere` and `hyphens: auto`,
|
||||||
|
so one long word (German "Wiedergabegeschwindigkeit") breaks inside the
|
||||||
|
sheet's 84px heading column. Hyphenation needs a `lang` on `<html>`.
|
||||||
|
With subtitles on but no track marked selected yet (the engine can report
|
||||||
|
the switch before the track list), the subtitle chip reads "On"
|
||||||
|
(`SUBTITLES_ON`), never "Off".
|
||||||
|
Every focused option shows the `--pc-text` ring; a selected swatch has a
|
||||||
|
white border with a dark inner gap, and focus adds an outer ring. The
|
||||||
|
subtitle group carries the load-file action (a plain button outside the
|
||||||
|
radio group) and the delay / size / color sections that the popover used
|
||||||
|
to hold (same `player-controls-load-subtitle`,
|
||||||
`player-controls-subtitle-delay`, `player-controls-subtitle-style` test
|
`player-controls-subtitle-delay`, `player-controls-subtitle-style` test
|
||||||
ids). The panel is a `role="dialog"` with `tabindex="-1"`: opened from
|
ids). The panel is a `role="dialog"` with `tabindex="-1"`: opened from
|
||||||
the keyboard (the opener is `:focus-visible`) it takes focus, a pointer
|
the keyboard (the opener is `:focus-visible`) it takes focus, a pointer
|
||||||
@@ -1639,6 +1669,7 @@ libs/ui/playback/src/lib/player-controls/
|
|||||||
├── player-settings-panel.component.ts
|
├── player-settings-panel.component.ts
|
||||||
├── player-settings-panel.component.html
|
├── player-settings-panel.component.html
|
||||||
├── player-settings-panel.component.scss
|
├── player-settings-panel.component.scss
|
||||||
|
├── settings-radio-group.directive.ts
|
||||||
├── controls-fullscreen.ts
|
├── controls-fullscreen.ts
|
||||||
├── controls-menu-selection.ts
|
├── controls-menu-selection.ts
|
||||||
├── controls-menu-state.ts
|
├── controls-menu-state.ts
|
||||||
|
|||||||
@@ -366,6 +366,16 @@
|
|||||||
settings.available() && layout.roomy() && !settings.isOpen()
|
settings.available() && layout.roomy() && !settings.isOpen()
|
||||||
) {
|
) {
|
||||||
@if (settings.groups().subtitles) {
|
@if (settings.groups().subtitles) {
|
||||||
|
<!-- On with no track marked selected yet (the engine
|
||||||
|
can report the id before the list) reads "On",
|
||||||
|
never "Off". The name carries the value
|
||||||
|
("Subtitles: English"); the label is the value. -->
|
||||||
|
@let subtitleValue =
|
||||||
|
settings.subtitleLabel() ??
|
||||||
|
((settings.subtitlesOn()
|
||||||
|
? 'EMBEDDED_MPV.PLAYER.SUBTITLES_ON'
|
||||||
|
: 'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
|
||||||
|
) | translate);
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="player-controls__chip"
|
class="player-controls__chip"
|
||||||
@@ -378,10 +388,12 @@
|
|||||||
settings.onSubtitleChipContextMenu($event)
|
settings.onSubtitleChipContextMenu($event)
|
||||||
"
|
"
|
||||||
[attr.aria-label]="
|
[attr.aria-label]="
|
||||||
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
|
'EMBEDDED_MPV.PLAYER.SUBTITLES_TOOLTIP'
|
||||||
|
| translate: { subtitles: subtitleValue }
|
||||||
"
|
"
|
||||||
[matTooltip]="
|
[matTooltip]="
|
||||||
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
|
'EMBEDDED_MPV.PLAYER.SUBTITLES_TOOLTIP'
|
||||||
|
| translate: { subtitles: subtitleValue }
|
||||||
"
|
"
|
||||||
matTooltipPosition="above"
|
matTooltipPosition="above"
|
||||||
>
|
>
|
||||||
@@ -391,9 +403,7 @@
|
|||||||
: 'closed_caption_disabled'
|
: 'closed_caption_disabled'
|
||||||
}}</mat-icon>
|
}}</mat-icon>
|
||||||
<span class="player-controls__chip-label">{{
|
<span class="player-controls__chip-label">{{
|
||||||
settings.subtitleLabel() ??
|
subtitleValue
|
||||||
('EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
|
|
||||||
| translate)
|
|
||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -407,7 +417,9 @@
|
|||||||
data-test-id="player-controls-speed-chip"
|
data-test-id="player-controls-speed-chip"
|
||||||
(click)="settings.open('speed')"
|
(click)="settings.open('speed')"
|
||||||
[attr.aria-label]="
|
[attr.aria-label]="
|
||||||
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
|
'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP'
|
||||||
|
| translate
|
||||||
|
: { speed: settings.speedLabel() }
|
||||||
"
|
"
|
||||||
[matTooltip]="
|
[matTooltip]="
|
||||||
'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP'
|
'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP'
|
||||||
@@ -434,6 +446,7 @@
|
|||||||
"
|
"
|
||||||
data-test-id="player-controls-settings-button"
|
data-test-id="player-controls-settings-button"
|
||||||
(click)="settings.toggle()"
|
(click)="settings.toggle()"
|
||||||
|
aria-haspopup="dialog"
|
||||||
[attr.aria-expanded]="settings.isOpen()"
|
[attr.aria-expanded]="settings.isOpen()"
|
||||||
[attr.aria-label]="
|
[attr.aria-label]="
|
||||||
'EMBEDDED_MPV.PLAYER.SETTINGS' | translate
|
'EMBEDDED_MPV.PLAYER.SETTINGS' | translate
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
--pc-track: rgba(255, 255, 255, 0.18);
|
--pc-track: rgba(255, 255, 255, 0.18);
|
||||||
--pc-hover: rgba(255, 255, 255, 0.1);
|
--pc-hover: rgba(255, 255, 255, 0.1);
|
||||||
--pc-glass-bg: rgba(12, 16, 23, 0.72);
|
--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-glass-border: rgba(255, 255, 255, 0.09);
|
||||||
--pc-font-mono:
|
--pc-font-mono:
|
||||||
ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
|
ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||||
@@ -493,10 +496,20 @@
|
|||||||
--mat-icon-button-state-layer-size: 40px;
|
--mat-icon-button-state-layer-size: 40px;
|
||||||
--mat-icon-button-container-shape: 12px;
|
--mat-icon-button-container-shape: 12px;
|
||||||
--mat-icon-button-hover-state-layer-opacity: 0;
|
--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;
|
border-radius: 12px;
|
||||||
transition: background 120ms ease-out;
|
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 {
|
:host :is(button[mat-icon-button]) mat-icon {
|
||||||
color: var(--pc-text);
|
color: var(--pc-text);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -219,7 +219,9 @@ describe('PlayerControlsComponent settings panel', () => {
|
|||||||
'player-controls__settings--sheet'
|
'player-controls__settings--sheet'
|
||||||
);
|
);
|
||||||
expect(panel?.getAttribute('role')).toBe('dialog');
|
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(
|
expect(
|
||||||
query('[data-test-id="player-settings-speed"]')?.classList
|
query('[data-test-id="player-settings-speed"]')?.classList
|
||||||
).toContain('player-settings__group--focused');
|
).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', () => {
|
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 });
|
setCapabilities({ externalSubtitles: true });
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(query('[aria-label="Subtitles"]')).not.toBeNull();
|
expect(chip()).not.toBeNull();
|
||||||
openSubtitleMenu();
|
openSubtitleMenu();
|
||||||
// No track list entries: no Off row without a selectable track…
|
// No track list entries: no Off row without a selectable track…
|
||||||
expect(query('.player-settings__option--selected')).toBeNull();
|
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 HOST_STYLES = STYLE_SOURCES.get('player-controls.component.scss') ?? '';
|
||||||
const TIMELINE_STYLES =
|
const TIMELINE_STYLES =
|
||||||
STYLE_SOURCES.get('player-timeline.component.scss') ?? '';
|
STYLE_SOURCES.get('player-timeline.component.scss') ?? '';
|
||||||
|
const SETTINGS_STYLES =
|
||||||
|
STYLE_SOURCES.get('player-settings-panel.component.scss') ?? '';
|
||||||
|
|
||||||
type Rgb = [number, number, number];
|
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}"`);
|
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 {
|
function contrastRatio(foreground: Rgb, background: Rgb): number {
|
||||||
const luminance = (rgb: Rgb): number => {
|
const luminance = (rgb: Rgb): number => {
|
||||||
const [red, green, blue] = rgb.map((channel) => {
|
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', () => {
|
it('keeps the palette reds readable on video', () => {
|
||||||
const white: Rgb = [255, 255, 255];
|
const white: Rgb = [255, 255, 255];
|
||||||
const glass = paletteColor('--pc-glass-bg');
|
const glass = paletteColor('--pc-glass-bg');
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { WritableSignal, signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
|
import {
|
||||||
|
DEFAULT_PLAYER_CAPABILITIES,
|
||||||
|
createEmptyControlsState,
|
||||||
|
} from './player-controls-defaults';
|
||||||
|
import { PlayerControlsComponent } from './player-controls.component';
|
||||||
|
import type {
|
||||||
|
PlayerControlsCapabilities,
|
||||||
|
PlayerControlsCommands,
|
||||||
|
PlayerControlsState,
|
||||||
|
PlayerController,
|
||||||
|
} from './player-controls.model';
|
||||||
|
|
||||||
|
function createFakeController() {
|
||||||
|
const capabilities: WritableSignal<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_ON: 'On',
|
||||||
|
SUBTITLES_TOOLTIP: 'Subtitles: {{subtitles}}',
|
||||||
|
SUBTITLE_DELAY: 'Subtitle delay',
|
||||||
|
SUBTITLE_SIZE: 'Subtitle size',
|
||||||
|
SUBTITLE_COLOR: 'Subtitle color',
|
||||||
|
SUBTITLE_COLOR_DEFAULT: 'Default',
|
||||||
|
QUALITY: 'Quality',
|
||||||
|
QUALITY_AUTO: 'Auto',
|
||||||
|
PLAYBACK_SPEED: 'Playback speed',
|
||||||
|
SPEED_TOOLTIP: 'Speed: {{speed}}',
|
||||||
|
ASPECT_RATIO: 'Aspect ratio',
|
||||||
|
ASPECT_DEFAULT: 'Default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
translate.use('en');
|
||||||
|
|
||||||
|
fake = createFakeController();
|
||||||
|
fixture = TestBed.createComponent(PlayerControlsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.componentRef.setInput('controller', fake.controller);
|
||||||
|
fixture.detectChanges();
|
||||||
|
document.body.appendChild(fixture.nativeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fixture.nativeElement.remove();
|
||||||
|
fixture.destroy();
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the dialog and every radio group by a real heading', () => {
|
||||||
|
openPanel();
|
||||||
|
const panel = query('[data-test-id="player-controls-settings-panel"]');
|
||||||
|
const title = panel?.querySelector('h2');
|
||||||
|
expect(panel?.getAttribute('aria-labelledby')).toBe(title?.id);
|
||||||
|
expect(title?.textContent?.trim()).toBe('Settings');
|
||||||
|
|
||||||
|
const groups = Array.from(
|
||||||
|
panel?.querySelectorAll('[role="radiogroup"]') ?? []
|
||||||
|
).map((group) => {
|
||||||
|
const heading = document.getElementById(
|
||||||
|
group.getAttribute('aria-labelledby') ?? ''
|
||||||
|
);
|
||||||
|
return `${heading?.tagName} ${heading?.textContent?.trim()}`;
|
||||||
|
});
|
||||||
|
expect(groups).toEqual([
|
||||||
|
'H3 Audio tracks',
|
||||||
|
'H3 Subtitles',
|
||||||
|
'H4 Subtitle size',
|
||||||
|
'H4 Subtitle color',
|
||||||
|
'H3 Quality',
|
||||||
|
'H3 Playback speed',
|
||||||
|
'H3 Aspect ratio',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
document.getElementById(
|
||||||
|
query(
|
||||||
|
'[data-test-id="player-controls-subtitle-delay"] [role="group"]'
|
||||||
|
)?.getAttribute('aria-labelledby') ?? ''
|
||||||
|
)?.textContent
|
||||||
|
).toContain('Subtitle delay');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers radios only, with the load-file action outside the group', () => {
|
||||||
|
openPanel();
|
||||||
|
const panel = query(
|
||||||
|
'[data-test-id="player-controls-settings-panel"]'
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
expect(panel.querySelectorAll('[role^="menuitem"]')).toHaveLength(0);
|
||||||
|
const radioParents = Array.from(
|
||||||
|
panel.querySelectorAll('[role="radio"]')
|
||||||
|
).map((radio) => radio.parentElement?.getAttribute('role'));
|
||||||
|
expect(radioParents.length).toBeGreaterThan(0);
|
||||||
|
expect(new Set(radioParents)).toEqual(new Set(['radiogroup']));
|
||||||
|
|
||||||
|
const load = query('[data-test-id="player-controls-load-subtitle"]');
|
||||||
|
expect(load?.hasAttribute('role')).toBe(false);
|
||||||
|
expect(load?.closest('[role="radiogroup"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives each group one Tab stop on its checked option, else the first', () => {
|
||||||
|
fake.state.update((state) => ({ ...state, playbackSpeed: 1.1 }));
|
||||||
|
openPanel();
|
||||||
|
|
||||||
|
expect(tabStops('audio')).toEqual(['English']);
|
||||||
|
// Tracks, then the subtitle size and colour groups of the section.
|
||||||
|
expect(tabStops('subtitles')).toEqual(['Off', '100%', 'Default']);
|
||||||
|
expect(tabStops('quality')).toEqual(['Auto']);
|
||||||
|
// No preset matches 1.1×: the first option keeps the group reachable.
|
||||||
|
expect(tabStops('speed')).toEqual(['0.5×']);
|
||||||
|
expect(tabStops('aspect')).toEqual(['Default']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves focus with the arrow keys, Home and End and checks the option reached', () => {
|
||||||
|
openPanel();
|
||||||
|
const checked = radios('speed').find(
|
||||||
|
(radio) => radio.getAttribute('aria-checked') === 'true'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
checked.focus();
|
||||||
|
expect(checked.textContent?.trim()).toBe('1×');
|
||||||
|
|
||||||
|
expect(press(checked, 'ArrowRight').defaultPrevented).toBe(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(document.activeElement?.textContent?.trim()).toBe('1.25×');
|
||||||
|
expect(tabStops('speed')).toEqual(['1.25×']);
|
||||||
|
|
||||||
|
// The ends wrap, as in a native radio group.
|
||||||
|
const reached = [
|
||||||
|
'ArrowDown',
|
||||||
|
'ArrowUp',
|
||||||
|
'ArrowLeft',
|
||||||
|
'End',
|
||||||
|
'ArrowRight',
|
||||||
|
'ArrowLeft',
|
||||||
|
'Home',
|
||||||
|
].map((key) => {
|
||||||
|
press(document.activeElement as HTMLElement, key);
|
||||||
|
return document.activeElement?.textContent?.trim();
|
||||||
|
});
|
||||||
|
expect(reached).toEqual([
|
||||||
|
'1.5×',
|
||||||
|
'1.25×',
|
||||||
|
'1×',
|
||||||
|
'2×',
|
||||||
|
'0.5×',
|
||||||
|
'2×',
|
||||||
|
'0.5×',
|
||||||
|
]);
|
||||||
|
// Every move applies its option, 1× included: the (fake) engine
|
||||||
|
// still reports 1× checked, but 1.25× is the pending request.
|
||||||
|
expect(
|
||||||
|
fake.commands.setPlaybackSpeed.mock.calls.map(([speed]) => speed)
|
||||||
|
).toEqual([1.25, 1.5, 1.25, 1, 2, 0.5, 2, 0.5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps arrow keys inside their own group', () => {
|
||||||
|
openPanel();
|
||||||
|
const [english, german] = radios('audio');
|
||||||
|
english.focus();
|
||||||
|
press(english, 'ArrowDown');
|
||||||
|
expect(document.activeElement).toBe(german);
|
||||||
|
press(german, 'ArrowDown');
|
||||||
|
expect(document.activeElement).toBe(english);
|
||||||
|
// Returning before the engine confirms the first switch applies
|
||||||
|
// English again; otherwise the pending switch would win.
|
||||||
|
expect(fake.commands.setAudioTrack.mock.calls).toEqual([[2], [1]]);
|
||||||
|
expect(fake.commands.setPlaybackSpeed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands the Tab stop back to the reported checked option when focus leaves', () => {
|
||||||
|
// The fake engine never confirms the switch the arrow key requested,
|
||||||
|
// so English stays the checked track.
|
||||||
|
openPanel();
|
||||||
|
const [english, german] = radios('audio');
|
||||||
|
english.focus();
|
||||||
|
press(english, 'ArrowDown');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tabStops('audio')).toEqual(['German']);
|
||||||
|
expect(german.tabIndex).toBe(0);
|
||||||
|
|
||||||
|
query('[data-test-id="player-settings-close"]')?.focus();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tabStops('audio')).toEqual(['English']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reverses the horizontal arrows in a right-to-left layout', () => {
|
||||||
|
openPanel();
|
||||||
|
const speed = radios('speed');
|
||||||
|
(speed[0].parentElement as HTMLElement).style.direction = 'rtl';
|
||||||
|
speed[2].focus();
|
||||||
|
|
||||||
|
press(speed[2], 'ArrowLeft');
|
||||||
|
expect(document.activeElement).toBe(speed[3]);
|
||||||
|
press(speed[3], 'ArrowRight');
|
||||||
|
expect(document.activeElement).toBe(speed[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the chips with their current values', () => {
|
||||||
|
const subtitleChip = () =>
|
||||||
|
query('[data-test-id="player-controls-subtitle-chip"]');
|
||||||
|
const speedChip = () =>
|
||||||
|
query('[data-test-id="player-controls-speed-chip"]');
|
||||||
|
expect(subtitleChip()?.getAttribute('aria-label')).toBe(
|
||||||
|
'Subtitles: Off'
|
||||||
|
);
|
||||||
|
expect(speedChip()?.getAttribute('aria-label')).toBe('Speed: 1×');
|
||||||
|
|
||||||
|
fake.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
|
||||||
|
subtitlesEnabled: true,
|
||||||
|
playbackSpeed: 1.25,
|
||||||
|
}));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(subtitleChip()?.getAttribute('aria-label')).toBe(
|
||||||
|
'Subtitles: Russian'
|
||||||
|
);
|
||||||
|
expect(speedChip()?.getAttribute('aria-label')).toBe('Speed: 1.25×');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the subtitle chip "On" while no enabled track is marked selected', () => {
|
||||||
|
// The engine can report subtitles on before the track list marks the
|
||||||
|
// selected track; the chip must not claim they are off.
|
||||||
|
fake.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
subtitleTracks: [{ id: 5, label: 'Russian', selected: false }],
|
||||||
|
subtitlesEnabled: true,
|
||||||
|
}));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const chip = query('[data-test-id="player-controls-subtitle-chip"]');
|
||||||
|
expect(chip?.getAttribute('aria-label')).toBe('Subtitles: On');
|
||||||
|
expect(
|
||||||
|
chip?.querySelector('.player-controls__chip-label')?.textContent
|
||||||
|
).toContain('On');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces that the tune button opens a dialog', () => {
|
||||||
|
const tune = query('[data-test-id="player-controls-settings-button"]');
|
||||||
|
expect(tune?.getAttribute('aria-haspopup')).toBe('dialog');
|
||||||
|
expect(tune?.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
<div class="player-settings__grip" aria-hidden="true"></div>
|
<div class="player-settings__grip" aria-hidden="true"></div>
|
||||||
}
|
}
|
||||||
<div class="player-settings__header">
|
<div class="player-settings__header">
|
||||||
<div class="player-settings__title">{{ title() }}</div>
|
<h2 class="player-settings__title" [id]="headingId('title')">
|
||||||
|
{{ title() }}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -24,29 +26,26 @@
|
|||||||
data-group="audio"
|
data-group="audio"
|
||||||
data-test-id="player-settings-audio"
|
data-test-id="player-settings-audio"
|
||||||
>
|
>
|
||||||
<div class="player-settings__heading">
|
<h3 class="player-settings__heading" [id]="headingId('audio')">
|
||||||
<span
|
<span
|
||||||
class="player-settings__dot player-settings__dot--cyan"
|
class="player-settings__dot player-settings__dot--cyan"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
{{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }}
|
{{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }}
|
||||||
</div>
|
</h3>
|
||||||
<div
|
<div
|
||||||
class="player-settings__options"
|
class="player-settings__options"
|
||||||
role="group"
|
appSettingsRadioGroup
|
||||||
[attr.aria-label]="
|
[attr.aria-labelledby]="headingId('audio')"
|
||||||
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
@for (track of state().audioTracks; track track.id) {
|
@for (track of state().audioTracks; track track.id) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitemradio"
|
|
||||||
class="player-settings__option"
|
class="player-settings__option"
|
||||||
|
[appSettingsRadio]="track.selected"
|
||||||
[class.player-settings__option--selected]="
|
[class.player-settings__option--selected]="
|
||||||
track.selected
|
track.selected
|
||||||
"
|
"
|
||||||
[attr.aria-checked]="track.selected"
|
|
||||||
(click)="selection().audioTrack(track.id)"
|
(click)="selection().audioTrack(track.id)"
|
||||||
>
|
>
|
||||||
<span>{{ track.label }}</span>
|
<span>{{ track.label }}</span>
|
||||||
@@ -66,82 +65,86 @@
|
|||||||
data-group="subtitles"
|
data-group="subtitles"
|
||||||
data-test-id="player-settings-subtitles"
|
data-test-id="player-settings-subtitles"
|
||||||
>
|
>
|
||||||
<div class="player-settings__heading">
|
<h3 class="player-settings__heading" [id]="headingId('subtitles')">
|
||||||
<span
|
<span
|
||||||
class="player-settings__dot player-settings__dot--cyan"
|
class="player-settings__dot player-settings__dot--cyan"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }}
|
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }}
|
||||||
</div>
|
</h3>
|
||||||
<div
|
@if (state().subtitleTracks.length > 0) {
|
||||||
class="player-settings__options"
|
<div
|
||||||
role="group"
|
class="player-settings__options"
|
||||||
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate"
|
appSettingsRadioGroup
|
||||||
>
|
[attr.aria-labelledby]="headingId('subtitles')"
|
||||||
@if (
|
>
|
||||||
capabilities().subtitles &&
|
@if (capabilities().subtitles) {
|
||||||
state().subtitleTracks.length > 0
|
<button
|
||||||
) {
|
type="button"
|
||||||
<button
|
class="player-settings__option player-settings__option--default"
|
||||||
type="button"
|
[appSettingsRadio]="!state().subtitlesEnabled"
|
||||||
role="menuitemradio"
|
[class.player-settings__option--selected]="
|
||||||
class="player-settings__option player-settings__option--default"
|
!state().subtitlesEnabled
|
||||||
[class.player-settings__option--selected]="
|
"
|
||||||
!state().subtitlesEnabled
|
(click)="selection().subtitleTrack(-1)"
|
||||||
"
|
>
|
||||||
[attr.aria-checked]="!state().subtitlesEnabled"
|
<span>{{
|
||||||
(click)="selection().subtitleTrack(-1)"
|
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate
|
||||||
>
|
}}</span>
|
||||||
<span>{{
|
@if (!state().subtitlesEnabled) {
|
||||||
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate
|
<mat-icon>check</mat-icon>
|
||||||
}}</span>
|
}
|
||||||
@if (!state().subtitlesEnabled) {
|
</button>
|
||||||
<mat-icon>check</mat-icon>
|
}
|
||||||
}
|
@for (track of state().subtitleTracks; track track.id) {
|
||||||
</button>
|
<button
|
||||||
}
|
type="button"
|
||||||
@for (track of state().subtitleTracks; track track.id) {
|
class="player-settings__option"
|
||||||
<button
|
[appSettingsRadio]="track.selected"
|
||||||
type="button"
|
[class.player-settings__option--selected]="
|
||||||
role="menuitemradio"
|
track.selected
|
||||||
class="player-settings__option"
|
"
|
||||||
[class.player-settings__option--selected]="
|
(click)="selection().subtitleTrack(track.id)"
|
||||||
track.selected
|
>
|
||||||
"
|
<span>{{ track.label }}</span>
|
||||||
[attr.aria-checked]="track.selected"
|
@if (track.selected) {
|
||||||
(click)="selection().subtitleTrack(track.id)"
|
<mat-icon>check</mat-icon>
|
||||||
>
|
}
|
||||||
<span>{{ track.label }}</span>
|
</button>
|
||||||
@if (track.selected) {
|
}
|
||||||
<mat-icon>check</mat-icon>
|
</div>
|
||||||
}
|
}
|
||||||
</button>
|
@if (capabilities().externalSubtitles) {
|
||||||
}
|
<!-- An action, not a choice: kept out of the radio group. -->
|
||||||
@if (capabilities().externalSubtitles) {
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="player-settings__option player-settings__option--action"
|
||||||
role="menuitem"
|
data-test-id="player-controls-load-subtitle"
|
||||||
class="player-settings__option player-settings__option--action"
|
(click)="loadExternalSubtitle()"
|
||||||
data-test-id="player-controls-load-subtitle"
|
>
|
||||||
(click)="loadExternalSubtitle()"
|
<span>{{
|
||||||
>
|
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE' | translate
|
||||||
<span>{{
|
}}</span>
|
||||||
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE' | translate
|
<mat-icon>upload_file</mat-icon>
|
||||||
}}</span>
|
</button>
|
||||||
<mat-icon>upload_file</mat-icon>
|
}
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (capabilities().subtitleDelay) {
|
@if (capabilities().subtitleDelay) {
|
||||||
<div
|
<div
|
||||||
class="player-settings__subsection"
|
class="player-settings__subsection"
|
||||||
data-test-id="player-controls-subtitle-delay"
|
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 }}
|
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }}
|
||||||
</div>
|
</h4>
|
||||||
<div class="player-settings__delay-row">
|
<div
|
||||||
|
class="player-settings__delay-row"
|
||||||
|
role="group"
|
||||||
|
[attr.aria-labelledby]="headingId('subtitle-delay')"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -212,15 +215,16 @@
|
|||||||
class="player-settings__subsection"
|
class="player-settings__subsection"
|
||||||
data-test-id="player-controls-subtitle-style"
|
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 }}
|
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate }}
|
||||||
</div>
|
</h4>
|
||||||
<div
|
<div
|
||||||
class="player-settings__seg"
|
class="player-settings__seg"
|
||||||
role="radiogroup"
|
appSettingsRadioGroup
|
||||||
[attr.aria-label]="
|
[attr.aria-labelledby]="headingId('subtitle-size')"
|
||||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
@for (
|
@for (
|
||||||
preset of subtitleSizePresets;
|
preset of subtitleSizePresets;
|
||||||
@@ -228,13 +232,12 @@
|
|||||||
) {
|
) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
|
||||||
class="player-settings__seg-item"
|
class="player-settings__seg-item"
|
||||||
[class.player-settings__seg-item--selected]="
|
[appSettingsRadio]="
|
||||||
preset.value ===
|
preset.value ===
|
||||||
state().subtitleStyle.sizePercent
|
state().subtitleStyle.sizePercent
|
||||||
"
|
"
|
||||||
[attr.aria-checked]="
|
[class.player-settings__seg-item--selected]="
|
||||||
preset.value ===
|
preset.value ===
|
||||||
state().subtitleStyle.sizePercent
|
state().subtitleStyle.sizePercent
|
||||||
"
|
"
|
||||||
@@ -246,15 +249,16 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="player-settings__subheading">
|
<h4
|
||||||
|
class="player-settings__subheading"
|
||||||
|
[id]="headingId('subtitle-color')"
|
||||||
|
>
|
||||||
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }}
|
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }}
|
||||||
</div>
|
</h4>
|
||||||
<div
|
<div
|
||||||
class="player-settings__swatches"
|
class="player-settings__swatches"
|
||||||
role="radiogroup"
|
appSettingsRadioGroup
|
||||||
[attr.aria-label]="
|
[attr.aria-labelledby]="headingId('subtitle-color')"
|
||||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
@for (
|
@for (
|
||||||
preset of subtitleColorPresets;
|
preset of subtitleColorPresets;
|
||||||
@@ -262,8 +266,10 @@
|
|||||||
) {
|
) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
|
||||||
class="player-settings__swatch"
|
class="player-settings__swatch"
|
||||||
|
[appSettingsRadio]="
|
||||||
|
preset.value === state().subtitleStyle.color
|
||||||
|
"
|
||||||
[class.player-settings__swatch--default]="
|
[class.player-settings__swatch--default]="
|
||||||
preset.value === null
|
preset.value === null
|
||||||
"
|
"
|
||||||
@@ -271,9 +277,6 @@
|
|||||||
preset.value === state().subtitleStyle.color
|
preset.value === state().subtitleStyle.color
|
||||||
"
|
"
|
||||||
[style.--swatch-color]="preset.value"
|
[style.--swatch-color]="preset.value"
|
||||||
[attr.aria-checked]="
|
|
||||||
preset.value === state().subtitleStyle.color
|
|
||||||
"
|
|
||||||
[attr.aria-label]="preset.label | translate"
|
[attr.aria-label]="preset.label | translate"
|
||||||
[matTooltip]="preset.label | translate"
|
[matTooltip]="preset.label | translate"
|
||||||
(click)="
|
(click)="
|
||||||
@@ -294,26 +297,25 @@
|
|||||||
data-group="quality"
|
data-group="quality"
|
||||||
data-test-id="player-settings-quality"
|
data-test-id="player-settings-quality"
|
||||||
>
|
>
|
||||||
<div class="player-settings__heading">
|
<h3 class="player-settings__heading" [id]="headingId('quality')">
|
||||||
<span
|
<span
|
||||||
class="player-settings__dot player-settings__dot--violet"
|
class="player-settings__dot player-settings__dot--violet"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
{{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }}
|
{{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }}
|
||||||
</div>
|
</h3>
|
||||||
<div
|
<div
|
||||||
class="player-settings__options"
|
class="player-settings__options"
|
||||||
role="group"
|
appSettingsRadioGroup
|
||||||
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.QUALITY' | translate"
|
[attr.aria-labelledby]="headingId('quality')"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitemradio"
|
|
||||||
class="player-settings__option player-settings__option--default"
|
class="player-settings__option player-settings__option--default"
|
||||||
|
[appSettingsRadio]="state().qualityAutoEnabled"
|
||||||
[class.player-settings__option--selected]="
|
[class.player-settings__option--selected]="
|
||||||
state().qualityAutoEnabled
|
state().qualityAutoEnabled
|
||||||
"
|
"
|
||||||
[attr.aria-checked]="state().qualityAutoEnabled"
|
|
||||||
(click)="selection().qualityLevel(-1)"
|
(click)="selection().qualityLevel(-1)"
|
||||||
>
|
>
|
||||||
<span>{{
|
<span>{{
|
||||||
@@ -326,12 +328,11 @@
|
|||||||
@for (level of state().qualityLevels; track level.id) {
|
@for (level of state().qualityLevels; track level.id) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitemradio"
|
|
||||||
class="player-settings__option"
|
class="player-settings__option"
|
||||||
|
[appSettingsRadio]="level.selected"
|
||||||
[class.player-settings__option--selected]="
|
[class.player-settings__option--selected]="
|
||||||
level.selected
|
level.selected
|
||||||
"
|
"
|
||||||
[attr.aria-checked]="level.selected"
|
|
||||||
(click)="selection().qualityLevel(level.id)"
|
(click)="selection().qualityLevel(level.id)"
|
||||||
>
|
>
|
||||||
<span>{{ level.label }}</span>
|
<span>{{ level.label }}</span>
|
||||||
@@ -351,34 +352,31 @@
|
|||||||
data-group="speed"
|
data-group="speed"
|
||||||
data-test-id="player-settings-speed"
|
data-test-id="player-settings-speed"
|
||||||
>
|
>
|
||||||
<div class="player-settings__heading">
|
<h3 class="player-settings__heading" [id]="headingId('speed')">
|
||||||
<span
|
<span
|
||||||
class="player-settings__dot player-settings__dot--violet"
|
class="player-settings__dot player-settings__dot--violet"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
{{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }}
|
{{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }}
|
||||||
</div>
|
</h3>
|
||||||
<div
|
<div
|
||||||
class="player-settings__seg"
|
class="player-settings__seg"
|
||||||
role="radiogroup"
|
appSettingsRadioGroup
|
||||||
[attr.aria-label]="
|
[attr.aria-labelledby]="headingId('speed')"
|
||||||
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
@for (preset of state().speedPresets; track preset.value) {
|
@for (preset of state().speedPresets; track preset.value) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
|
||||||
class="player-settings__seg-item"
|
class="player-settings__seg-item"
|
||||||
|
[appSettingsRadio]="
|
||||||
|
preset.value === state().playbackSpeed
|
||||||
|
"
|
||||||
[class.player-settings__seg-item--default]="
|
[class.player-settings__seg-item--default]="
|
||||||
preset.value === 1
|
preset.value === 1
|
||||||
"
|
"
|
||||||
[class.player-settings__seg-item--selected]="
|
[class.player-settings__seg-item--selected]="
|
||||||
preset.value === state().playbackSpeed
|
preset.value === state().playbackSpeed
|
||||||
"
|
"
|
||||||
[attr.aria-checked]="
|
|
||||||
preset.value === state().playbackSpeed
|
|
||||||
"
|
|
||||||
(click)="selection().speed(preset.value)"
|
(click)="selection().speed(preset.value)"
|
||||||
>
|
>
|
||||||
{{ preset.label }}
|
{{ preset.label }}
|
||||||
@@ -395,34 +393,31 @@
|
|||||||
data-group="aspect"
|
data-group="aspect"
|
||||||
data-test-id="player-settings-aspect"
|
data-test-id="player-settings-aspect"
|
||||||
>
|
>
|
||||||
<div class="player-settings__heading">
|
<h3 class="player-settings__heading" [id]="headingId('aspect')">
|
||||||
<span
|
<span
|
||||||
class="player-settings__dot player-settings__dot--neutral"
|
class="player-settings__dot player-settings__dot--neutral"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
{{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }}
|
{{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }}
|
||||||
</div>
|
</h3>
|
||||||
<div
|
<div
|
||||||
class="player-settings__seg"
|
class="player-settings__seg"
|
||||||
role="radiogroup"
|
appSettingsRadioGroup
|
||||||
[attr.aria-label]="
|
[attr.aria-labelledby]="headingId('aspect')"
|
||||||
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
@for (preset of state().aspectPresets; track preset.value) {
|
@for (preset of state().aspectPresets; track preset.value) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
|
||||||
class="player-settings__seg-item"
|
class="player-settings__seg-item"
|
||||||
|
[appSettingsRadio]="
|
||||||
|
preset.value === state().aspectRatio
|
||||||
|
"
|
||||||
[class.player-settings__seg-item--default]="
|
[class.player-settings__seg-item--default]="
|
||||||
preset.value === defaultAspect()
|
preset.value === defaultAspect()
|
||||||
"
|
"
|
||||||
[class.player-settings__seg-item--selected]="
|
[class.player-settings__seg-item--selected]="
|
||||||
preset.value === state().aspectRatio
|
preset.value === state().aspectRatio
|
||||||
"
|
"
|
||||||
[attr.aria-checked]="
|
|
||||||
preset.value === state().aspectRatio
|
|
||||||
"
|
|
||||||
(click)="selection().aspect(preset.value)"
|
(click)="selection().aspect(preset.value)"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 18px 16px;
|
padding: 18px 16px;
|
||||||
color: var(--pc-text, #e7ecf3);
|
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: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09));
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||||
@@ -69,7 +69,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-settings__title {
|
.player-settings__title {
|
||||||
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -90,6 +93,9 @@
|
|||||||
--mat-icon-button-container-shape: 10px;
|
--mat-icon-button-container-shape: 10px;
|
||||||
--mat-icon-button-icon-size: 20px;
|
--mat-icon-button-icon-size: 20px;
|
||||||
--mat-icon-button-hover-state-layer-opacity: 0;
|
--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;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +110,13 @@
|
|||||||
background: var(--pc-hover, rgba(255, 255, 255, 0.1));
|
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]),
|
||||||
:host :is(button[mat-icon-button][disabled]) mat-icon {
|
:host :is(button[mat-icon-button][disabled]) mat-icon {
|
||||||
color: var(--pc-text-tertiary, #6b7384);
|
color: var(--pc-text-tertiary, #6b7384);
|
||||||
@@ -147,17 +160,33 @@
|
|||||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12);
|
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 {
|
.player-settings__heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 0 4px 4px;
|
padding: 0 4px 4px;
|
||||||
color: var(--pc-text-tertiary, #6b7384);
|
}
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
.player-settings__heading .player-settings__dot {
|
||||||
letter-spacing: 0.08em;
|
flex: 0 0 auto;
|
||||||
text-transform: uppercase;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-settings__dot {
|
.player-settings__dot {
|
||||||
@@ -300,12 +329,6 @@
|
|||||||
|
|
||||||
.player-settings__subheading {
|
.player-settings__subheading {
|
||||||
padding: 0 4px;
|
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 {
|
.player-settings__delay-row {
|
||||||
@@ -326,7 +349,9 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 2px 4px 4px;
|
// Room on every side for the focus ring (2px offset + 2px): the body
|
||||||
|
// scrolls and would clip it at its edge.
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-settings__swatch {
|
.player-settings__swatch {
|
||||||
@@ -352,10 +377,22 @@
|
|||||||
rgba(255, 255, 255, 0.08);
|
rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-settings__swatch:hover,
|
.player-settings__swatch:hover {
|
||||||
.player-settings__swatch:focus-visible,
|
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 {
|
.player-settings__swatch--selected {
|
||||||
border-color: #ffffff;
|
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
|
// Sheet density: two-column rows (heading | control) so five groups fit a
|
||||||
@@ -367,15 +404,15 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The heading column is narrow: let "Playback speed" wrap onto two lines
|
// The heading column is narrow: "Playback speed" wraps onto two lines, and a
|
||||||
// instead of running under the control.
|
// single long word breaks (see the heading rule), instead of running under
|
||||||
|
// the control.
|
||||||
:host(.player-settings--sheet) .player-settings__heading {
|
:host(.player-settings--sheet) .player-settings__heading {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 6px 0 0;
|
padding: 6px 0 0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
white-space: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:host(.player-settings--sheet) .player-settings__heading .player-settings__dot {
|
:host(.player-settings--sheet) .player-settings__heading .player-settings__dot {
|
||||||
@@ -410,6 +447,13 @@
|
|||||||
display: none;
|
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 {
|
:host(.player-settings--sheet) .player-settings__seg-item {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import type { ControlsSettings } from './controls-settings';
|
|||||||
import type { SettingsGroup } from './controls-settings-groups';
|
import type { SettingsGroup } from './controls-settings-groups';
|
||||||
import type { ControlsSubtitleSettings } from './controls-subtitle-settings';
|
import type { ControlsSubtitleSettings } from './controls-subtitle-settings';
|
||||||
import type { PlayerController } from './player-controls.model';
|
import type { PlayerController } from './player-controls.model';
|
||||||
|
import {
|
||||||
|
SettingsRadioDirective,
|
||||||
|
SettingsRadioGroupDirective,
|
||||||
|
} from './settings-radio-group.directive';
|
||||||
import {
|
import {
|
||||||
SUBTITLE_COLOR_PRESETS,
|
SUBTITLE_COLOR_PRESETS,
|
||||||
SUBTITLE_DELAY_STEP_SECONDS,
|
SUBTITLE_DELAY_STEP_SECONDS,
|
||||||
@@ -28,6 +32,8 @@ import {
|
|||||||
|
|
||||||
export type PlayerSettingsPanelMode = 'panel' | 'sheet';
|
export type PlayerSettingsPanelMode = 'panel' | 'sheet';
|
||||||
|
|
||||||
|
let nextPanelId = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The settings surface behind the dock's `tune` button: audio, subtitles
|
* The settings surface behind the dock's `tune` button: audio, subtitles
|
||||||
* (tracks, file loading, delay, size and color), quality, speed and aspect
|
* (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',
|
selector: 'app-player-settings-panel',
|
||||||
templateUrl: './player-settings-panel.component.html',
|
templateUrl: './player-settings-panel.component.html',
|
||||||
styleUrl: './player-settings-panel.component.scss',
|
styleUrl: './player-settings-panel.component.scss',
|
||||||
imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe],
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
SettingsRadioDirective,
|
||||||
|
SettingsRadioGroupDirective,
|
||||||
|
TranslatePipe,
|
||||||
|
],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
host: {
|
host: {
|
||||||
class: 'player-settings',
|
class: 'player-settings',
|
||||||
role: 'dialog',
|
role: 'dialog',
|
||||||
tabindex: '-1',
|
tabindex: '-1',
|
||||||
'[class.player-settings--sheet]': 'mode() === "sheet"',
|
'[class.player-settings--sheet]': 'mode() === "sheet"',
|
||||||
'[attr.aria-label]': 'title()',
|
'[attr.aria-labelledby]': 'headingId("title")',
|
||||||
'(focusin)': 'focusInside = true',
|
'(focusin)': 'focusInside = true',
|
||||||
'(focusout)': 'onFocusOut($event)',
|
'(focusout)': 'onFocusOut($event)',
|
||||||
},
|
},
|
||||||
@@ -54,6 +67,8 @@ export type PlayerSettingsPanelMode = 'panel' | 'sheet';
|
|||||||
export class PlayerSettingsPanelComponent implements OnDestroy {
|
export class PlayerSettingsPanelComponent implements OnDestroy {
|
||||||
private readonly host: HTMLElement =
|
private readonly host: HTMLElement =
|
||||||
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
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 controller = input.required<PlayerController>();
|
||||||
readonly settings = input.required<ControlsSettings>();
|
readonly settings = input.required<ControlsSettings>();
|
||||||
readonly selection = input.required<ControlsMenuSelection>();
|
readonly selection = input.required<ControlsMenuSelection>();
|
||||||
@@ -133,6 +148,11 @@ export class PlayerSettingsPanelComponent implements OnDestroy {
|
|||||||
return this.focusGroup() === group;
|
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 {
|
loadExternalSubtitle(): void {
|
||||||
if (!this.capabilities().externalSubtitles) {
|
if (!this.capabilities().externalSubtitles) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the option through its own click handler, as a press would.
|
||||||
|
* Unconditionally: the engine may still report this option checked
|
||||||
|
* while a switch away from it is pending, and returning to it must
|
||||||
|
* cancel that switch.
|
||||||
|
*/
|
||||||
|
check(): void {
|
||||||
|
this.element.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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` and check the
|
||||||
|
* option they reach, as a native radio group does. Checking clicks the
|
||||||
|
* option, so the template's handler applies the choice to the player.
|
||||||
|
*/
|
||||||
|
@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');
|
||||||
|
const previous = this.keyManager.activeItem;
|
||||||
|
this.keyManager.onKeydown(event);
|
||||||
|
const next = this.keyManager.activeItem;
|
||||||
|
if (next && next !== previous) {
|
||||||
|
next.check();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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",
|
"angular-eslint": "22.5.0",
|
||||||
"astro": "7.2.10",
|
"astro": "7.2.10",
|
||||||
"autoprefixer": "10.5.4",
|
"autoprefixer": "10.5.4",
|
||||||
|
"axe-core": "4.13.0",
|
||||||
"cookie": "2.0.1",
|
"cookie": "2.0.1",
|
||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
|
|||||||
Generated
+9
@@ -371,6 +371,9 @@ importers:
|
|||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: 10.5.4
|
specifier: 10.5.4
|
||||||
version: 10.5.4(postcss@8.5.26)
|
version: 10.5.4(postcss@8.5.26)
|
||||||
|
axe-core:
|
||||||
|
specifier: 4.13.0
|
||||||
|
version: 4.13.0
|
||||||
cookie:
|
cookie:
|
||||||
specifier: 2.0.1
|
specifier: 2.0.1
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
@@ -4901,6 +4904,10 @@ packages:
|
|||||||
aws4@1.13.2:
|
aws4@1.13.2:
|
||||||
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
|
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:
|
axios@1.18.1:
|
||||||
resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
|
resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
|
||||||
|
|
||||||
@@ -14512,6 +14519,8 @@ snapshots:
|
|||||||
|
|
||||||
aws4@1.13.2: {}
|
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):
|
axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))
|
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))
|
||||||
|
|||||||
Reference in new issue
Block a user