mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
* fix(player): keyboard focus, contrast and ARIA for controls and settings
Dock and settings-panel icon buttons draw a 2px --pc-text ring on
:focus-visible, and Material's theme-coloured focus layer is off, so
keyboard focus shows on video in the light theme too. A focused selected
subtitle swatch now differs from one that is only selected.
Settings headings read --pc-text-secondary on denser glass
(--pc-glass-bg-dense, 0.86): 4.5:1 or more over mid-grey and white
frames. They wrap (overflow-wrap: anywhere, hyphens: auto), so long
German and Russian headings stay inside the sheet's heading column.
The settings panel is now radio groups only (SettingsRadioGroupDirective
over a CDK FocusKeyManager): one Tab stop per group on the checked option,
arrows, Home and End move focus without applying, and Space/Enter checks.
The dialog and its groups are named by real h2/h3/h4 headings, the
load-file action sits outside the subtitle radio group, the subtitle and
speed chips carry their value in their name ("Subtitles: English"), and
tune has aria-haspopup="dialog".
Adds a web E2E for the keyboard path in both themes with an axe check on
the open panel, and de/ru sheet heading wrapping; axe-core is a new dev
dependency for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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>
183 lines
6.6 KiB
TypeScript
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;
|
|
}
|
|
}
|