Files
iptvnator/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html
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

435 lines
18 KiB
HTML

@if (mode() === 'sheet') {
<div class="player-settings__grip" aria-hidden="true"></div>
}
<div class="player-settings__header">
<h2 class="player-settings__title" [id]="headingId('title')">
{{ title() }}
</h2>
<button
mat-icon-button
type="button"
class="player-settings__close"
data-test-id="player-settings-close"
(click)="closeRequested.emit()"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.CLOSE_SETTINGS' | translate"
[matTooltip]="'EMBEDDED_MPV.PLAYER.CLOSE_SETTINGS' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
<div class="player-settings__body">
@if (groups().audio) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('audio')"
data-group="audio"
data-test-id="player-settings-audio"
>
<h3 class="player-settings__heading" [id]="headingId('audio')">
<span
class="player-settings__dot player-settings__dot--cyan"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }}
</h3>
<div
class="player-settings__options"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('audio')"
>
@for (track of state().audioTracks; track track.id) {
<button
type="button"
class="player-settings__option"
[appSettingsRadio]="track.selected"
[class.player-settings__option--selected]="
track.selected
"
(click)="selection().audioTrack(track.id)"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</section>
}
@if (groups().subtitles) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('subtitles')"
data-group="subtitles"
data-test-id="player-settings-subtitles"
>
<h3 class="player-settings__heading" [id]="headingId('subtitles')">
<span
class="player-settings__dot player-settings__dot--cyan"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }}
</h3>
@if (state().subtitleTracks.length > 0) {
<div
class="player-settings__options"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('subtitles')"
>
@if (capabilities().subtitles) {
<button
type="button"
class="player-settings__option player-settings__option--default"
[appSettingsRadio]="!state().subtitlesEnabled"
[class.player-settings__option--selected]="
!state().subtitlesEnabled
"
(click)="selection().subtitleTrack(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate
}}</span>
@if (!state().subtitlesEnabled) {
<mat-icon>check</mat-icon>
}
</button>
}
@for (track of state().subtitleTracks; track track.id) {
<button
type="button"
class="player-settings__option"
[appSettingsRadio]="track.selected"
[class.player-settings__option--selected]="
track.selected
"
(click)="selection().subtitleTrack(track.id)"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
}
@if (capabilities().externalSubtitles) {
<!-- An action, not a choice: kept out of the radio group. -->
<button
type="button"
class="player-settings__option player-settings__option--action"
data-test-id="player-controls-load-subtitle"
(click)="loadExternalSubtitle()"
>
<span>{{
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE' | translate
}}</span>
<mat-icon>upload_file</mat-icon>
</button>
}
@if (capabilities().subtitleDelay) {
<div
class="player-settings__subsection"
data-test-id="player-controls-subtitle-delay"
>
<h4
class="player-settings__subheading"
[id]="headingId('subtitle-delay')"
>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }}
</h4>
<div
class="player-settings__delay-row"
role="group"
[attr.aria-labelledby]="headingId('subtitle-delay')"
>
<button
mat-icon-button
type="button"
(click)="
subtitleSettings().adjustDelay(
-subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
>
<mat-icon>remove</mat-icon>
</button>
<span
class="player-settings__delay-value"
aria-live="polite"
>{{
subtitleDelayLabel(state().subtitleDelaySeconds)
}}</span
>
<button
mat-icon-button
type="button"
(click)="
subtitleSettings().adjustDelay(
subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
>
<mat-icon>add</mat-icon>
</button>
<button
mat-icon-button
type="button"
[disabled]="state().subtitleDelaySeconds === 0"
(click)="subtitleSettings().resetDelay()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
>
<mat-icon>restart_alt</mat-icon>
</button>
</div>
</div>
}
@if (capabilities().subtitleStyle) {
<div
class="player-settings__subsection"
data-test-id="player-controls-subtitle-style"
>
<h4
class="player-settings__subheading"
[id]="headingId('subtitle-size')"
>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate }}
</h4>
<div
class="player-settings__seg"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('subtitle-size')"
>
@for (
preset of subtitleSizePresets;
track preset.value
) {
<button
type="button"
class="player-settings__seg-item"
[appSettingsRadio]="
preset.value ===
state().subtitleStyle.sizePercent
"
[class.player-settings__seg-item--selected]="
preset.value ===
state().subtitleStyle.sizePercent
"
(click)="
subtitleSettings().setSize(preset.value)
"
>
{{ preset.label }}
</button>
}
</div>
<h4
class="player-settings__subheading"
[id]="headingId('subtitle-color')"
>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }}
</h4>
<div
class="player-settings__swatches"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('subtitle-color')"
>
@for (
preset of subtitleColorPresets;
track preset.label
) {
<button
type="button"
class="player-settings__swatch"
[appSettingsRadio]="
preset.value === state().subtitleStyle.color
"
[class.player-settings__swatch--default]="
preset.value === null
"
[class.player-settings__swatch--selected]="
preset.value === state().subtitleStyle.color
"
[style.--swatch-color]="preset.value"
[attr.aria-label]="preset.label | translate"
[matTooltip]="preset.label | translate"
(click)="
subtitleSettings().setColor(preset.value)
"
></button>
}
</div>
</div>
}
</section>
}
@if (groups().quality) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('quality')"
data-group="quality"
data-test-id="player-settings-quality"
>
<h3 class="player-settings__heading" [id]="headingId('quality')">
<span
class="player-settings__dot player-settings__dot--violet"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }}
</h3>
<div
class="player-settings__options"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('quality')"
>
<button
type="button"
class="player-settings__option player-settings__option--default"
[appSettingsRadio]="state().qualityAutoEnabled"
[class.player-settings__option--selected]="
state().qualityAutoEnabled
"
(click)="selection().qualityLevel(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.QUALITY_AUTO' | translate
}}</span>
@if (state().qualityAutoEnabled) {
<mat-icon>check</mat-icon>
}
</button>
@for (level of state().qualityLevels; track level.id) {
<button
type="button"
class="player-settings__option"
[appSettingsRadio]="level.selected"
[class.player-settings__option--selected]="
level.selected
"
(click)="selection().qualityLevel(level.id)"
>
<span>{{ level.label }}</span>
@if (level.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</section>
}
@if (groups().speed) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('speed')"
data-group="speed"
data-test-id="player-settings-speed"
>
<h3 class="player-settings__heading" [id]="headingId('speed')">
<span
class="player-settings__dot player-settings__dot--violet"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }}
</h3>
<div
class="player-settings__seg"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('speed')"
>
@for (preset of state().speedPresets; track preset.value) {
<button
type="button"
class="player-settings__seg-item"
[appSettingsRadio]="
preset.value === state().playbackSpeed
"
[class.player-settings__seg-item--default]="
preset.value === 1
"
[class.player-settings__seg-item--selected]="
preset.value === state().playbackSpeed
"
(click)="selection().speed(preset.value)"
>
{{ preset.label }}
</button>
}
</div>
</section>
}
@if (groups().aspect) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('aspect')"
data-group="aspect"
data-test-id="player-settings-aspect"
>
<h3 class="player-settings__heading" [id]="headingId('aspect')">
<span
class="player-settings__dot player-settings__dot--neutral"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }}
</h3>
<div
class="player-settings__seg"
appSettingsRadioGroup
[attr.aria-labelledby]="headingId('aspect')"
>
@for (preset of state().aspectPresets; track preset.value) {
<button
type="button"
class="player-settings__seg-item"
[appSettingsRadio]="
preset.value === state().aspectRatio
"
[class.player-settings__seg-item--default]="
preset.value === defaultAspect()
"
[class.player-settings__seg-item--selected]="
preset.value === state().aspectRatio
"
(click)="selection().aspect(preset.value)"
>
{{
preset.value === 'no'
? ('EMBEDDED_MPV.PLAYER.ASPECT_DEFAULT'
| translate)
: preset.label
}}
</button>
}
</div>
</section>
}
</div>