Files
iptvnator/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts
T
4grayandClaude Opus 5.5 adb4889b0f 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>
2026-10-01 06:37:02 +02:00

183 lines
6.6 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
afterNextRender,
computed,
effect,
inject,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import type { ControlsMenuSelection } from './controls-menu-selection';
import type { ControlsSettings } from './controls-settings';
import type { SettingsGroup } from './controls-settings-groups';
import type { ControlsSubtitleSettings } from './controls-subtitle-settings';
import type { PlayerController } from './player-controls.model';
import {
SettingsRadioDirective,
SettingsRadioGroupDirective,
} from './settings-radio-group.directive';
import {
SUBTITLE_COLOR_PRESETS,
SUBTITLE_DELAY_STEP_SECONDS,
SUBTITLE_SIZE_PRESETS,
subtitleDelayLabel,
} from './subtitle-style';
export type PlayerSettingsPanelMode = 'panel' | 'sheet';
let nextPanelId = 0;
/**
* The settings surface behind the dock's `tune` button: audio, subtitles
* (tracks, file loading, delay, size and color), quality, speed and aspect
* ratio in one place. Wide players show it as a right-hand panel beside the
* video, compact players as a bottom sheet. It only renders state and
* forwards choices to the collaborators the controls component owns.
*/
@Component({
selector: 'app-player-settings-panel',
templateUrl: './player-settings-panel.component.html',
styleUrl: './player-settings-panel.component.scss',
imports: [
MatButtonModule,
MatIconModule,
MatTooltipModule,
SettingsRadioDirective,
SettingsRadioGroupDirective,
TranslatePipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-settings',
role: 'dialog',
tabindex: '-1',
'[class.player-settings--sheet]': 'mode() === "sheet"',
'[attr.aria-labelledby]': 'headingId("title")',
'(focusin)': 'focusInside = true',
'(focusout)': 'onFocusOut($event)',
},
})
export class PlayerSettingsPanelComponent implements OnDestroy {
private readonly host: HTMLElement =
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
/** Several players can be mounted at once: heading ids stay unique. */
private readonly idPrefix = `player-settings-${nextPanelId++}`;
readonly controller = input.required<PlayerController>();
readonly settings = input.required<ControlsSettings>();
readonly selection = input.required<ControlsMenuSelection>();
readonly subtitleSettings = input.required<ControlsSubtitleSettings>();
readonly mode = input<PlayerSettingsPanelMode>('panel');
readonly title = input('');
readonly closeRequested = output<void>();
readonly state = computed(() => this.controller().state());
readonly capabilities = computed(() => this.controller().capabilities());
readonly groups = computed(() => this.settings().groups());
readonly focusGroup = computed(() => this.settings().focusGroup());
readonly defaultAspect = computed(() => this.settings().defaultAspect());
readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS;
readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS;
readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS;
readonly subtitleDelayLabel = subtitleDelayLabel;
/** The control that opened the panel, and whether the keyboard did. */
private readonly opener = document.activeElement;
private readonly openedByKeyboard =
this.opener instanceof HTMLElement &&
this.opener !== document.body &&
!!this.opener.closest('.player-controls-host') &&
matchesFocusVisible(this.opener);
focusInside = false;
/** Recorded after render: on destroy the panel is already detached. */
private controlsHost: Element | null = null;
constructor() {
// Keyboard users land inside the dialog; a pointer open leaves focus
// alone (a focused control would capture Space from the shortcuts).
afterNextRender(() => {
this.controlsHost = this.host.closest('.player-controls-host');
if (this.openedByKeyboard) {
this.host.focus({ preventScroll: true });
this.focusInside = this.host.contains(document.activeElement);
}
});
// A chip click opens the panel on its group: bring that group into
// view so a long audio list cannot push the speed row off-screen.
effect(() => {
const group = this.focusGroup();
if (!group) {
return;
}
queueMicrotask(() => this.scrollGroupIntoView(group));
});
}
onFocusOut(event: FocusEvent): void {
const next = event.relatedTarget;
this.focusInside = next instanceof Node && this.host.contains(next);
}
/**
* Closing with focus inside (Escape, the close button by keyboard)
* returns it to the `tune` button rather than dropping it on the page.
* The chip that may have opened the panel is re-rendered on close, so
* `tune` is the stable target. Tracked by flag, not `activeElement`:
* the view's DOM is already detached when this hook runs.
*/
ngOnDestroy(): void {
if (!this.focusInside) {
return;
}
const controls = this.controlsHost;
queueMicrotask(() =>
controls
?.querySelector<HTMLElement>('.player-controls__tune')
?.focus({ preventScroll: true })
);
}
isFocused(group: SettingsGroup): boolean {
return this.focusGroup() === group;
}
/** The id of a heading that names the dialog or one of its groups. */
headingId(name: string): string {
return `${this.idPrefix}-${name}`;
}
loadExternalSubtitle(): void {
if (!this.capabilities().externalSubtitles) {
return;
}
this.selection().externalSubtitle();
}
private scrollGroupIntoView(group: SettingsGroup): void {
const element = this.host.querySelector(`[data-group="${group}"]`);
if (
element instanceof HTMLElement &&
typeof element.scrollIntoView === 'function'
) {
element.scrollIntoView({ block: 'nearest' });
}
}
}
function matchesFocusVisible(element: Element | null): boolean {
try {
return (
element instanceof HTMLElement && element.matches(':focus-visible')
);
} catch {
return false;
}
}