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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-30 22:07:22 +02:00
1 parent cdc208dbff
commit 6c050f3175
25 files changed
+140 -66

No files matched your search

@@ -247,38 +247,38 @@ test('@web @playback keyboard reaches the dock and the settings radios with a vi
]);
expect(new Set(radioStops.map((stop) => stop.group)).size).toBe(3);
// --- Arrows move focus without applying; Space applies. -----------
// --- Arrows move focus and apply the option they reach. ----------
const speedGroup = panel.locator(
'[data-test-id="player-settings-speed"] [role="radiogroup"]'
);
const rateBefore = await video.evaluate(
(el: HTMLVideoElement) => el.playbackRate
);
const rate = () =>
video.evaluate((el: HTMLVideoElement) => el.playbackRate);
await page.keyboard.press('ArrowRight');
const moved = await focusStop(page);
expect(moved?.role).toBe('radio');
expect(moved?.checked).toBe('false');
expect(
await video.evaluate((el: HTMLVideoElement) => el.playbackRate)
).toBe(rateBefore);
await page.keyboard.press('Space');
await expect
.poll(() =>
video.evaluate((el: HTMLVideoElement) => el.playbackRate)
)
.toBe(Number.parseFloat(moved?.label ?? ''));
await expect.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 again.
// 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(moved?.label);
expect((await focusStop(page))?.label).toBe('2×');
// --- A focused selected swatch differs from a selected one. -------
await tabUntil(page, browserName, {
@@ -286,14 +286,24 @@ test('@web @playback keyboard reaches the dock and the settings radios with a vi
direction: 'backward',
max: 5,
});
const focusedSelected = panel.locator('.player-settings__swatch:focus');
await expect(focusedSelected).toHaveAttribute('aria-checked', 'true');
await expect(focusedSelected).toHaveCSS('outline-style', 'solid');
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"]:not(:focus)'
'.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',
@@ -313,13 +323,9 @@ test('@web @playback keyboard reaches the dock and the settings radios with a vi
path: test.info().outputPath(`${theme}-panel-focus.png`),
});
// A keyboard-focused swatch shows its tooltip, and Material spends
// the next Escape on a visible tooltip: close from a speed radio,
// which has none, once the swatch's tooltip has gone.
await pressTab(page, browserName);
expect(
await focusIsInside(page, '[data-test-id="player-settings-speed"]')
).toBe(true);
// 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);
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "مسارات الصوت",
"SUBTITLES": "الترجمات",
"SUBTITLES_OFF": "إيقاف",
"SUBTITLES_ON": "تشغيل",
"QUALITY": "الجودة",
"QUALITY_AUTO": "تلقائي",
"TRACK_DEFAULT": "افتراضي",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "مسارات الصوت",
"SUBTITLES": "الترجمة",
"SUBTITLES_OFF": "معطلة",
"SUBTITLES_ON": "مفعلة",
"QUALITY": "الجودة",
"QUALITY_AUTO": "تلقائي",
"TRACK_DEFAULT": "افتراضي",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Аўдыядарожкі",
"SUBTITLES": "Субтытры",
"SUBTITLES_OFF": "Выкл.",
"SUBTITLES_ON": "Укл.",
"QUALITY": "Якасць",
"QUALITY_AUTO": "Аўта",
"TRACK_DEFAULT": "Па змаўчанні",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Tonspuren",
"SUBTITLES": "Untertitel",
"SUBTITLES_OFF": "Aus",
"SUBTITLES_ON": "An",
"QUALITY": "Qualität",
"QUALITY_AUTO": "Automatisch",
"TRACK_DEFAULT": "Standard",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Κομμάτια ήχου",
"SUBTITLES": "Υπότιτλοι",
"SUBTITLES_OFF": "Ανενεργοί",
"SUBTITLES_ON": "Ενεργοί",
"QUALITY": "Ποιότητα",
"QUALITY_AUTO": "Αυτόματη",
"TRACK_DEFAULT": "Προεπιλογή",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Audio tracks",
"SUBTITLES": "Subtitles",
"SUBTITLES_OFF": "Off",
"SUBTITLES_ON": "On",
"QUALITY": "Quality",
"QUALITY_AUTO": "Auto",
"TRACK_DEFAULT": "Default",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Pistas de audio",
"SUBTITLES": "Subtítulos",
"SUBTITLES_OFF": "Desactivados",
"SUBTITLES_ON": "Activados",
"QUALITY": "Calidad",
"QUALITY_AUTO": "Automática",
"TRACK_DEFAULT": "Predeterminada",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Pistes audio",
"SUBTITLES": "Sous-titres",
"SUBTITLES_OFF": "Désactivés",
"SUBTITLES_ON": "Activés",
"QUALITY": "Qualité",
"QUALITY_AUTO": "Auto",
"TRACK_DEFAULT": "Par défaut",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Hangsávok",
"SUBTITLES": "Feliratok",
"SUBTITLES_OFF": "Kikapcsolva",
"SUBTITLES_ON": "Bekapcsolva",
"QUALITY": "Minőség",
"QUALITY_AUTO": "Automatikus",
"TRACK_DEFAULT": "Alapértelmezett",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Tracce audio",
"SUBTITLES": "Sottotitoli",
"SUBTITLES_OFF": "Disattivati",
"SUBTITLES_ON": "Attivati",
"QUALITY": "Qualità",
"QUALITY_AUTO": "Automatica",
"TRACK_DEFAULT": "Predefinita",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "音声トラック",
"SUBTITLES": "字幕",
"SUBTITLES_OFF": "オフ",
"SUBTITLES_ON": "オン",
"QUALITY": "画質",
"QUALITY_AUTO": "自動",
"TRACK_DEFAULT": "デフォルト",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "오디오 트랙",
"SUBTITLES": "자막",
"SUBTITLES_OFF": "끄기",
"SUBTITLES_ON": "켜기",
"QUALITY": "화질",
"QUALITY_AUTO": "자동",
"TRACK_DEFAULT": "기본",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Audiosporen",
"SUBTITLES": "Ondertiteling",
"SUBTITLES_OFF": "Uit",
"SUBTITLES_ON": "Aan",
"QUALITY": "Kwaliteit",
"QUALITY_AUTO": "Automatisch",
"TRACK_DEFAULT": "Standaard",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Ścieżki audio",
"SUBTITLES": "Napisy",
"SUBTITLES_OFF": "Wyłączone",
"SUBTITLES_ON": "Włączone",
"QUALITY": "Jakość",
"QUALITY_AUTO": "Automatycznie",
"TRACK_DEFAULT": "Domyślna",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Faixas de áudio",
"SUBTITLES": "Legendas",
"SUBTITLES_OFF": "Desativadas",
"SUBTITLES_ON": "Ativadas",
"QUALITY": "Qualidade",
"QUALITY_AUTO": "Automática",
"TRACK_DEFAULT": "Padrão",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Аудиодорожки",
"SUBTITLES": "Субтитры",
"SUBTITLES_OFF": "Выкл.",
"SUBTITLES_ON": "Вкл.",
"QUALITY": "Качество",
"QUALITY_AUTO": "Авто",
"TRACK_DEFAULT": "По умолчанию",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "Ses parçaları",
"SUBTITLES": "Altyazılar",
"SUBTITLES_OFF": "Kapalı",
"SUBTITLES_ON": "Açık",
"QUALITY": "Kalite",
"QUALITY_AUTO": "Otomatik",
"TRACK_DEFAULT": "Varsayılan",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "音轨",
"SUBTITLES": "字幕",
"SUBTITLES_OFF": "关闭",
"SUBTITLES_ON": "开启",
"QUALITY": "画质",
"QUALITY_AUTO": "自动",
"TRACK_DEFAULT": "默认",
+1
View File
@@ -619,6 +619,7 @@
"AUDIO_TRACKS": "音軌",
"SUBTITLES": "字幕",
"SUBTITLES_OFF": "關閉",
"SUBTITLES_ON": "開啟",
"QUALITY": "畫質",
"QUALITY_AUTO": "自動",
"TRACK_DEFAULT": "預設",
+11 -8
View File
@@ -473,20 +473,23 @@ the panel the moment the last group disappears.
(audio, subtitles, quality) are rows with a check mark and a cyan
selection; segmented groups (speed, aspect, subtitle size) have a violet
selection, and a selected default (`1×`, the first aspect preset) stays
neutral. Each group is one Tab stop — the checked option, else the first —
and arrows, Home and End move focus with a CDK `FocusKeyManager`
(wrapping; the horizontal arrows follow `direction`). Moving focus does not
apply: a choice is applied to the playing stream at once and a track or
quality switch rebuffers, so Space / Enter checks, as in a toolbar radio
group. Focus changes also write the roving `tabindex` immediately, because
a quick Shift+Tab, Tab can arrive before change detection updates the
bindings. The dialog is named by its `h2` title (`aria-labelledby`), each
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. An
option the engine already reports as checked is not applied again. 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
@@ -366,11 +366,16 @@
settings.available() && layout.roomy() && !settings.isOpen()
) {
@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() ??
('EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate);
<!-- The name carries the value ("Subtitles: English"):
the label alone is only the value. -->
((settings.subtitlesOn()
? 'EMBEDDED_MPV.PLAYER.SUBTITLES_ON'
: 'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
) | translate);
<button
type="button"
class="player-controls__chip"
@@ -127,6 +127,7 @@ describe('PlayerSettingsPanelComponent accessibility', () => {
AUDIO_TRACKS: 'Audio tracks',
SUBTITLES: 'Subtitles',
SUBTITLES_OFF: 'Off',
SUBTITLES_ON: 'On',
SUBTITLES_TOOLTIP: 'Subtitles: {{subtitles}}',
SUBTITLE_DELAY: 'Subtitle delay',
SUBTITLE_SIZE: 'Subtitle size',
@@ -221,10 +222,9 @@ describe('PlayerSettingsPanelComponent accessibility', () => {
expect(tabStops('aspect')).toEqual(['Default']);
});
it('moves focus with the arrow keys, Home and End without applying a choice', () => {
it('moves focus with the arrow keys, Home and End and checks the option reached', () => {
openPanel();
const speed = radios('speed');
const checked = speed.find(
const checked = radios('speed').find(
(radio) => radio.getAttribute('aria-checked') === 'true'
) as HTMLButtonElement;
checked.focus();
@@ -235,24 +235,33 @@ describe('PlayerSettingsPanelComponent accessibility', () => {
expect(document.activeElement?.textContent?.trim()).toBe('1.25×');
expect(tabStops('speed')).toEqual(['1.25×']);
press(document.activeElement as HTMLElement, 'ArrowDown');
expect(document.activeElement?.textContent?.trim()).toBe('1.5×');
press(document.activeElement as HTMLElement, 'ArrowUp');
press(document.activeElement as HTMLElement, 'ArrowLeft');
expect(document.activeElement?.textContent?.trim()).toBe('1×');
press(document.activeElement as HTMLElement, 'End');
expect(document.activeElement?.textContent?.trim()).toBe('2×');
// The ends wrap, as in a native radio group.
press(document.activeElement as HTMLElement, 'ArrowRight');
expect(document.activeElement?.textContent?.trim()).toBe('0.5×');
press(document.activeElement as HTMLElement, 'ArrowLeft');
expect(document.activeElement?.textContent?.trim()).toBe('2×');
press(document.activeElement as HTMLElement, 'Home');
expect(document.activeElement?.textContent?.trim()).toBe('0.5×');
expect(fake.commands.setPlaybackSpeed).not.toHaveBeenCalled();
(document.activeElement as HTMLButtonElement).click();
expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(0.5);
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 — except onto 1×, which the (fake)
// engine still reports as checked, so it is not applied again.
expect(
fake.commands.setPlaybackSpeed.mock.calls.map(([speed]) => speed)
).toEqual([1.25, 1.5, 1.25, 2, 0.5, 2, 0.5]);
});
it('keeps arrow keys inside their own group', () => {
@@ -263,10 +272,13 @@ describe('PlayerSettingsPanelComponent accessibility', () => {
expect(document.activeElement).toBe(german);
press(german, 'ArrowDown');
expect(document.activeElement).toBe(english);
expect(fake.commands.setAudioTrack).not.toHaveBeenCalled();
expect(fake.commands.setAudioTrack.mock.calls).toEqual([[2]]);
expect(fake.commands.setPlaybackSpeed).not.toHaveBeenCalled();
});
it('hands the Tab stop back to the checked option when focus leaves', () => {
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();
@@ -316,6 +328,23 @@ describe('PlayerSettingsPanelComponent accessibility', () => {
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');
@@ -349,7 +349,9 @@
flex-wrap: wrap;
align-items: center;
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 {
@@ -42,6 +42,13 @@ export class SettingsRadioDirective implements FocusableOption {
this.group.onOptionFocus(this);
}
/** Checks the option through its own click handler, as a press would. */
check(): void {
if (!this.checked()) {
this.element.click();
}
}
/** Immediate write of what the `tabindex` binding renders next. */
setTabStop(isTabStop: boolean): void {
this.element.tabIndex = isTabStop ? 0 : -1;
@@ -52,11 +59,9 @@ export class SettingsRadioDirective implements FocusableOption {
* A radio group in the settings panel (audio, subtitles, quality, speed,
* aspect, subtitle size and colour). One option is a Tab stop — the checked
* one, or the first when none is — and the arrow keys, Home and End move
* focus between the options with a CDK `FocusKeyManager`. Moving focus does
* not check: every choice is applied to the playing stream at once, and a
* track or quality switch rebuffers, so arrowing through five levels must
* not switch five times. Space or Enter checks the focused option, as in a
* toolbar radio group.
* 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]',
@@ -104,7 +109,12 @@ export class SettingsRadioGroupDirective {
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. */