Files
iptvnator/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts
T
4grayandClaude Fable 5.1 e6ef8cdd54 perf(web): take Angular Material off the startup path (#1931)
Takes Angular Material off the renderer's startup path: renderer.initialBytes
1,596,201 -> 1,344,067 bytes (-252,134, 15.8%) at the branch point; merged with
master, where #1928 lands the form-field defaults per component, the branch
measures 1,345,807 (-188,797, 12.3% against master's baseline). The bundler
keeps each module in one chunk, so one eager import of any export carries the
module's whole code; each item removes the last eager importer of one Material
module.

- EPG progress and update panels: eager host + `@defer (when visible)` view
  with an `@error` notice; the startup spinner is static markup copied from
  mat-spinner.
- LazyMatDialog: the confirm dialog service, player service, parental lock
  prompt and shell actions resolve MatDialog on first use.
- The theme emits tokens for the 21 imported components only, through a
  `$material-components` list and meta.apply; tools/nx/check-material-theme-scope.mjs
  (CI) keeps it in step with the imports in both directions.
- No provideAnimations(): Material 22 animates with CSS and reads
  prefers-reduced-motion itself; apps/electron-backend-e2e/src/material-motion.e2e.ts
  proves dialogs, selects, menus, snack-bars and tooltips animate in and out and
  stop under reduced motion. Its renderer had deferred every DOM removal to the
  end of the tick, so the catalog list now tracks the grid offset from scroll
  events and the player settings panel decides "focus was inside" in a
  microtask, both with specs that detach the view first.
- The snack-bar stays, and with it button, the icon-button chunk and the CDK
  overlay (about 190 KB): root services inject it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 00:08:58 +02:00

196 lines
7.1 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;
if (next instanceof Node) {
this.focusInside = this.host.contains(next);
return;
}
// No related target: focus went to the page, or the panel is being
// removed with focus inside, which Chromium reports with this event
// while the panel is still attached. The close destroys the panel in
// the same task, before this microtask runs, so a removal leaves the
// flag for ngOnDestroy to act on.
queueMicrotask(() => {
this.focusInside =
this.host.isConnected &&
this.host.contains(document.activeElement);
});
}
/**
* 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;
}
}