mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor(embedded-mpv): split player component into focused units
The component had grown to ~1000 lines with session lifecycle, IPC plumbing, keyboard shortcuts, popover state, formatters, and view orchestration all living in one file. Per CLAUDE.md's 350–400 line hard cap, split into: - embedded-mpv-format.utils.ts (~100): pure helpers (formatTime, track labels, volume icon/label, persisted-volume access, measureBounds) and preset constants (SPEED_PRESETS, ASPECT_PRESETS, HIDDEN_BOUNDS). - embedded-mpv-shortcuts.ts (~90): EmbeddedMpvShortcuts class that owns the document keydown listener and routes through a handler interface; the component just provides callbacks. - embedded-mpv-ui-state.ts (~110): EmbeddedMpvMenuState (single-open popover state machine with toggle/open/close/closeAll + anyOpen signal) and EmbeddedMpvFeedback (transient overlay with auto-clearing flash). - embedded-mpv-session-controller.ts (~395): component-scoped Injectable that owns support/session/sessionId/stalled/retryToken signals, the session-update IPC subscription, bounds-sync (resize, scroll, overlay state), the stalled timer, and all per-session IPC operations (togglePaused, seekBy, seekTo, applyVolume, setAudioTrack, setSubtitleTrack, setSpeed, setAspect, retry). - embedded-mpv-player.component.ts (now ~540, was ~1000): view-only orchestration — view children, derived computed signals, DOM event listeners (pointerdown/pointermove/fullscreenchange/dblclick), and three effects (session start/teardown, overlay-active bounds sync, session payload → volume/timeUpdate fan-out). No behavior changes. Build clean, electron-backend tests still pass. Component is still over the 400-line cap but the bulk of its size is now the necessarily-coupled-to-view computed signals and constructor effects; the remaining over-cap delta is structural to a player root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: f957cd9849e0
This commit is contained in:
1 parent
8e3f559033
commit
0249a3a9f7
6 files changed
+940
-769
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import { EmbeddedMpvAudioTrack, EmbeddedMpvBounds } from 'shared-interfaces';
|
||||
|
||||
export const HIDDEN_BOUNDS: EmbeddedMpvBounds = Object.freeze({
|
||||
x: -100000,
|
||||
y: -100000,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}) as EmbeddedMpvBounds;
|
||||
|
||||
export const SPEED_PRESETS: ReadonlyArray<{ value: number; label: string }> = [
|
||||
{ value: 0.5, label: '0.5×' },
|
||||
{ value: 0.75, label: '0.75×' },
|
||||
{ value: 1, label: '1×' },
|
||||
{ value: 1.25, label: '1.25×' },
|
||||
{ value: 1.5, label: '1.5×' },
|
||||
{ value: 2, label: '2×' },
|
||||
];
|
||||
|
||||
export const ASPECT_PRESETS: ReadonlyArray<{ value: string; label: string }> = [
|
||||
{ value: 'no', label: 'Default' },
|
||||
{ value: '16:9', label: '16:9' },
|
||||
{ value: '4:3', label: '4:3' },
|
||||
{ value: '21:9', label: '21:9' },
|
||||
{ value: '2.35:1', label: '2.35:1' },
|
||||
];
|
||||
|
||||
export function formatTime(value: number | null | undefined): string {
|
||||
const safeValue = Math.max(0, Math.floor(value ?? 0));
|
||||
const hours = Math.floor(safeValue / 3600);
|
||||
const minutes = Math.floor((safeValue % 3600) / 60);
|
||||
const seconds = safeValue % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(
|
||||
seconds
|
||||
).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function audioTrackLabel(
|
||||
track: EmbeddedMpvAudioTrack,
|
||||
index: number
|
||||
): string {
|
||||
const label = track.title || track.language || `Audio ${index + 1}`;
|
||||
return track.defaultTrack ? `${label} · Default` : label;
|
||||
}
|
||||
|
||||
export function subtitleTrackLabel(
|
||||
track: EmbeddedMpvAudioTrack,
|
||||
index: number
|
||||
): string {
|
||||
const label = track.title || track.language || `Subtitle ${index + 1}`;
|
||||
return track.defaultTrack ? `${label} · Default` : label;
|
||||
}
|
||||
|
||||
export function speedLabel(speed: number): string {
|
||||
const value = Math.round(speed * 100) / 100;
|
||||
return `${value}×`;
|
||||
}
|
||||
|
||||
export function aspectLabel(aspect: string): string {
|
||||
const preset = ASPECT_PRESETS.find((p) => p.value === aspect);
|
||||
return preset?.label ?? aspect;
|
||||
}
|
||||
|
||||
export function volumeIcon(value: number): string {
|
||||
if (value <= 0) {
|
||||
return 'volume_off';
|
||||
}
|
||||
return value < 0.5 ? 'volume_down' : 'volume_up';
|
||||
}
|
||||
|
||||
export function volumeLabel(value: number): string {
|
||||
return `Volume ${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
export function readStoredVolume(): number {
|
||||
const rawValue = Number(localStorage.getItem('volume') ?? '1');
|
||||
if (Number.isNaN(rawValue)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(0, Math.min(1, rawValue));
|
||||
}
|
||||
|
||||
export function persistVolume(value: number): void {
|
||||
localStorage.setItem('volume', String(value));
|
||||
}
|
||||
|
||||
export function measureBounds(host: HTMLElement): EmbeddedMpvBounds {
|
||||
const rect = host.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.max(1, Math.round(rect.width)),
|
||||
height: Math.max(1, Math.round(rect.height)),
|
||||
};
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (feedbackOverlay(); as feedback) {
|
||||
@if (feedback.current(); as feedback) {
|
||||
<div
|
||||
class="embedded-mpv-player__feedback"
|
||||
[attr.data-feedback-key]="feedback.key"
|
||||
@@ -177,7 +177,7 @@
|
||||
>
|
||||
<mat-icon>{{ volumeIcon() }}</mat-icon>
|
||||
</button>
|
||||
@if (volumePopoverOpen()) {
|
||||
@if (menus.volumeOpen()) {
|
||||
<div
|
||||
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-popover"
|
||||
role="dialog"
|
||||
@@ -208,14 +208,14 @@
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
(click)="toggleAudioMenu()"
|
||||
[attr.aria-expanded]="audioMenuOpen()"
|
||||
[attr.aria-expanded]="menus.audioOpen()"
|
||||
aria-label="Audio tracks"
|
||||
matTooltip="Audio tracks"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>graphic_eq</mat-icon>
|
||||
</button>
|
||||
@if (audioMenuOpen()) {
|
||||
@if (menus.audioOpen()) {
|
||||
<div
|
||||
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
|
||||
role="menu"
|
||||
@@ -261,7 +261,7 @@
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
(click)="toggleSubtitleMenu()"
|
||||
[attr.aria-expanded]="subtitleMenuOpen()"
|
||||
[attr.aria-expanded]="menus.subtitleOpen()"
|
||||
aria-label="Subtitles"
|
||||
matTooltip="Subtitles"
|
||||
matTooltipPosition="above"
|
||||
@@ -272,7 +272,7 @@
|
||||
: 'subtitles'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
@if (subtitleMenuOpen()) {
|
||||
@if (menus.subtitleOpen()) {
|
||||
<div
|
||||
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
|
||||
role="menu"
|
||||
@@ -339,7 +339,7 @@
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
(click)="toggleSpeedMenu()"
|
||||
[attr.aria-expanded]="speedMenuOpen()"
|
||||
[attr.aria-expanded]="menus.speedOpen()"
|
||||
[attr.aria-label]="
|
||||
'Playback speed ' + speedLabel(playbackSpeed())
|
||||
"
|
||||
@@ -350,7 +350,7 @@
|
||||
>
|
||||
<mat-icon>speed</mat-icon>
|
||||
</button>
|
||||
@if (speedMenuOpen()) {
|
||||
@if (menus.speedOpen()) {
|
||||
<div
|
||||
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
|
||||
role="menu"
|
||||
@@ -394,7 +394,7 @@
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
(click)="toggleAspectMenu()"
|
||||
[attr.aria-expanded]="aspectMenuOpen()"
|
||||
[attr.aria-expanded]="menus.aspectOpen()"
|
||||
[attr.aria-label]="
|
||||
'Aspect ratio ' + aspectLabel(aspectOverride())
|
||||
"
|
||||
@@ -405,7 +405,7 @@
|
||||
>
|
||||
<mat-icon>aspect_ratio</mat-icon>
|
||||
</button>
|
||||
@if (aspectMenuOpen()) {
|
||||
@if (menus.aspectOpen()) {
|
||||
<div
|
||||
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
|
||||
role="menu"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,395 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
Injectable,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
import { HIDDEN_BOUNDS, measureBounds } from './embedded-mpv-format.utils';
|
||||
|
||||
const STALLED_TIMEOUT_MS = 30_000;
|
||||
|
||||
@Injectable()
|
||||
export class EmbeddedMpvSessionController {
|
||||
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
||||
readonly session = signal<EmbeddedMpvSession | null>(null);
|
||||
readonly sessionId = signal<string | null>(null);
|
||||
readonly stalled = signal(false);
|
||||
readonly retryToken = signal(0);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly unsubscribeSessionUpdate?: () => void;
|
||||
|
||||
private overlayActiveProvider: () => boolean = () => false;
|
||||
private activeBoundsSync: (() => void) | null = null;
|
||||
private boundsAnimationFrame: number | null = null;
|
||||
private stalledTimer: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.unsubscribeSessionUpdate =
|
||||
window.electron?.onEmbeddedMpvSessionUpdate?.((session) => {
|
||||
if (session.id !== this.sessionId()) {
|
||||
return;
|
||||
}
|
||||
this.session.set(session);
|
||||
});
|
||||
|
||||
if (window.electron?.getEmbeddedMpvSupport) {
|
||||
void this.loadSupport();
|
||||
} else {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: typeof window === 'undefined' ? 'web' : 'unknown',
|
||||
reason: 'Embedded MPV requires the Electron desktop build.',
|
||||
});
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const status = this.session()?.status ?? null;
|
||||
untracked(() => this.handleStalledTracking(status));
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.unsubscribeSessionUpdate?.();
|
||||
this.cancelStalledTimer();
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
this.boundsAnimationFrame = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setOverlayActiveProvider(provider: () => boolean): void {
|
||||
this.overlayActiveProvider = provider;
|
||||
}
|
||||
|
||||
triggerBoundsSync(): void {
|
||||
this.activeBoundsSync?.();
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.stalled.set(false);
|
||||
this.session.set(null);
|
||||
this.sessionId.set(null);
|
||||
this.retryToken.update((value) => value + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spin up an embedded MPV session bound to `host`. Returns a teardown
|
||||
* function the caller must invoke when the host or playback changes (or
|
||||
* the component tears down). All bounds and lifecycle bookkeeping lives
|
||||
* here so the component can stay view-focused.
|
||||
*/
|
||||
startSession(
|
||||
host: HTMLElement,
|
||||
playback: ResolvedPortalPlayback,
|
||||
initialVolume: number
|
||||
): () => void {
|
||||
let disposed = false;
|
||||
let activeSessionId: string | null = null;
|
||||
|
||||
const syncBounds = () => {
|
||||
if (!activeSessionId) {
|
||||
return;
|
||||
}
|
||||
const bounds = this.overlayActiveProvider()
|
||||
? HIDDEN_BOUNDS
|
||||
: measureBounds(host);
|
||||
void window.electron
|
||||
?.setEmbeddedMpvBounds(activeSessionId, bounds)
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const scheduleBoundsSync = () => {
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
}
|
||||
this.boundsAnimationFrame = requestAnimationFrame(() => {
|
||||
this.boundsAnimationFrame = null;
|
||||
syncBounds();
|
||||
});
|
||||
};
|
||||
|
||||
this.activeBoundsSync = scheduleBoundsSync;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => scheduleBoundsSync());
|
||||
resizeObserver.observe(host);
|
||||
window.addEventListener('resize', scheduleBoundsSync);
|
||||
window.addEventListener('scroll', scheduleBoundsSync, true);
|
||||
|
||||
const create = async () => {
|
||||
this.session.set(this.createLoadingSession(playback, initialVolume));
|
||||
await this.waitForStartupPaint();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prepared = await window.electron!.prepareEmbeddedMpv?.();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (prepared && !prepared.supported) {
|
||||
throw new Error(
|
||||
prepared.reason ??
|
||||
'Embedded MPV is not available in this environment.'
|
||||
);
|
||||
}
|
||||
if (prepared) {
|
||||
this.support.set(prepared);
|
||||
}
|
||||
|
||||
const created = await window.electron!.createEmbeddedMpvSession(
|
||||
measureBounds(host),
|
||||
playback.title,
|
||||
initialVolume
|
||||
);
|
||||
|
||||
if (disposed) {
|
||||
await window.electron!.disposeEmbeddedMpvSession(created.id);
|
||||
return;
|
||||
}
|
||||
|
||||
activeSessionId = created.id;
|
||||
this.sessionId.set(created.id);
|
||||
this.session.set(created);
|
||||
await window.electron!.loadEmbeddedMpvPlayback(created.id, playback);
|
||||
scheduleBoundsSync();
|
||||
};
|
||||
|
||||
void create().catch((error) =>
|
||||
this.session.set(this.createErrorSession(playback, initialVolume, error))
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', scheduleBoundsSync);
|
||||
window.removeEventListener('scroll', scheduleBoundsSync, true);
|
||||
|
||||
if (this.activeBoundsSync === scheduleBoundsSync) {
|
||||
this.activeBoundsSync = null;
|
||||
}
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
this.boundsAnimationFrame = null;
|
||||
}
|
||||
|
||||
const id = activeSessionId;
|
||||
activeSessionId = null;
|
||||
this.sessionId.set(null);
|
||||
this.session.set(null);
|
||||
|
||||
if (id) {
|
||||
void window.electron?.disposeEmbeddedMpvSession(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async togglePaused(): Promise<void> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvPaused) {
|
||||
return;
|
||||
}
|
||||
const updated = await window.electron.setEmbeddedMpvPaused(
|
||||
session.id,
|
||||
session.status !== 'paused'
|
||||
);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
async seekBy(deltaSeconds: number): Promise<boolean> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
|
||||
return false;
|
||||
}
|
||||
const next = Math.max(0, session.positionSeconds + deltaSeconds);
|
||||
const updated = await window.electron.seekEmbeddedMpv(session.id, next);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async seekTo(seconds: number): Promise<void> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
|
||||
return;
|
||||
}
|
||||
const updated = await window.electron.seekEmbeddedMpv(session.id, seconds);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
async applyVolume(value: number): Promise<void> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvVolume) {
|
||||
return;
|
||||
}
|
||||
const updated = await window.electron.setEmbeddedMpvVolume(session.id, value);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
async setAudioTrack(trackId: number): Promise<void> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvAudioTrack) {
|
||||
return;
|
||||
}
|
||||
const updated = await window.electron.setEmbeddedMpvAudioTrack(
|
||||
session.id,
|
||||
trackId
|
||||
);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
async setSubtitleTrack(trackId: number): Promise<void> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvSubtitleTrack) {
|
||||
return;
|
||||
}
|
||||
const updated = await window.electron.setEmbeddedMpvSubtitleTrack(
|
||||
session.id,
|
||||
trackId
|
||||
);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
async setSpeed(speed: number): Promise<void> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvSpeed) {
|
||||
return;
|
||||
}
|
||||
const updated = await window.electron.setEmbeddedMpvSpeed(session.id, speed);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
async setAspect(aspect: string): Promise<void> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvAspect) {
|
||||
return;
|
||||
}
|
||||
const updated = await window.electron.setEmbeddedMpvAspect(
|
||||
session.id,
|
||||
aspect
|
||||
);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSupport(): Promise<void> {
|
||||
try {
|
||||
this.support.set(await window.electron!.getEmbeddedMpvSupport());
|
||||
} catch (error) {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron?.platform ?? 'unknown',
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleStalledTracking(
|
||||
status: EmbeddedMpvSession['status'] | null
|
||||
): void {
|
||||
if (status === 'loading') {
|
||||
if (this.stalledTimer === null) {
|
||||
this.stalledTimer = window.setTimeout(() => {
|
||||
this.stalled.set(true);
|
||||
this.stalledTimer = null;
|
||||
}, STALLED_TIMEOUT_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelStalledTimer();
|
||||
if (this.stalled()) {
|
||||
this.stalled.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private cancelStalledTimer(): void {
|
||||
if (this.stalledTimer !== null) {
|
||||
clearTimeout(this.stalledTimer);
|
||||
this.stalledTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private createLoadingSession(
|
||||
playback: ResolvedPortalPlayback,
|
||||
volume: number
|
||||
): EmbeddedMpvSession {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: 'embedded-mpv-starting',
|
||||
title: playback.title,
|
||||
streamUrl: playback.streamUrl,
|
||||
status: 'loading',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
private createErrorSession(
|
||||
playback: ResolvedPortalPlayback,
|
||||
volume: number,
|
||||
error: unknown
|
||||
): EmbeddedMpvSession {
|
||||
const now = new Date().toISOString();
|
||||
this.sessionId.set(null);
|
||||
return {
|
||||
id: 'embedded-mpv-error',
|
||||
title: playback.title,
|
||||
streamUrl: playback.streamUrl,
|
||||
status: 'error',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
private waitForStartupPaint(): Promise<void> {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
export interface EmbeddedMpvShortcutHandlers {
|
||||
isAvailable: () => boolean;
|
||||
onEscape: () => void;
|
||||
togglePaused: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
seekBy: (deltaSeconds: number) => void;
|
||||
adjustVolume: (delta: number) => void;
|
||||
toggleMute: () => void;
|
||||
}
|
||||
|
||||
export class EmbeddedMpvShortcuts {
|
||||
private handlers: EmbeddedMpvShortcutHandlers | null = null;
|
||||
private readonly listener = (event: KeyboardEvent) => this.handle(event);
|
||||
|
||||
attach(handlers: EmbeddedMpvShortcutHandlers): void {
|
||||
this.handlers = handlers;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', this.listener);
|
||||
}
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('keydown', this.listener);
|
||||
}
|
||||
this.handlers = null;
|
||||
}
|
||||
|
||||
private handle(event: KeyboardEvent): void {
|
||||
const handlers = this.handlers;
|
||||
if (!handlers) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
handlers.onEscape();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldIgnore(event) || !handlers.isAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'k':
|
||||
case 'K':
|
||||
event.preventDefault();
|
||||
handlers.togglePaused();
|
||||
return;
|
||||
case 'f':
|
||||
case 'F':
|
||||
event.preventDefault();
|
||||
handlers.toggleFullscreen();
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
handlers.seekBy(-5);
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
handlers.seekBy(5);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
handlers.adjustVolume(0.05);
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
handlers.adjustVolume(-0.05);
|
||||
return;
|
||||
case 'm':
|
||||
case 'M':
|
||||
event.preventDefault();
|
||||
handlers.toggleMute();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldIgnore(event: KeyboardEvent): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
return true;
|
||||
}
|
||||
return target.isContentEditable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { computed, signal } from '@angular/core';
|
||||
|
||||
export type EmbeddedMpvMenu =
|
||||
| 'volume'
|
||||
| 'audio'
|
||||
| 'subtitle'
|
||||
| 'speed'
|
||||
| 'aspect';
|
||||
|
||||
/**
|
||||
* Tracks which menu/popover is currently open and exposes individual signals
|
||||
* the template binds to. Only one menu can be open at a time.
|
||||
*/
|
||||
export class EmbeddedMpvMenuState {
|
||||
readonly volumeOpen = signal(false);
|
||||
readonly audioOpen = signal(false);
|
||||
readonly subtitleOpen = signal(false);
|
||||
readonly speedOpen = signal(false);
|
||||
readonly aspectOpen = signal(false);
|
||||
|
||||
readonly anyOpen = computed(
|
||||
() =>
|
||||
this.volumeOpen() ||
|
||||
this.audioOpen() ||
|
||||
this.subtitleOpen() ||
|
||||
this.speedOpen() ||
|
||||
this.aspectOpen()
|
||||
);
|
||||
|
||||
toggle(menu: EmbeddedMpvMenu): void {
|
||||
const target = this.signalFor(menu);
|
||||
const next = !target();
|
||||
this.closeAll();
|
||||
target.set(next);
|
||||
}
|
||||
|
||||
open(menu: EmbeddedMpvMenu): void {
|
||||
if (this.signalFor(menu)()) {
|
||||
return;
|
||||
}
|
||||
this.closeAll();
|
||||
this.signalFor(menu).set(true);
|
||||
}
|
||||
|
||||
close(menu: EmbeddedMpvMenu): void {
|
||||
this.signalFor(menu).set(false);
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
this.volumeOpen.set(false);
|
||||
this.audioOpen.set(false);
|
||||
this.subtitleOpen.set(false);
|
||||
this.speedOpen.set(false);
|
||||
this.aspectOpen.set(false);
|
||||
}
|
||||
|
||||
private signalFor(menu: EmbeddedMpvMenu) {
|
||||
switch (menu) {
|
||||
case 'volume':
|
||||
return this.volumeOpen;
|
||||
case 'audio':
|
||||
return this.audioOpen;
|
||||
case 'subtitle':
|
||||
return this.subtitleOpen;
|
||||
case 'speed':
|
||||
return this.speedOpen;
|
||||
case 'aspect':
|
||||
return this.aspectOpen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient feedback overlay shown when the user adjusts volume/seek/mute via
|
||||
* keyboard. Caller calls flash() with an icon + label; auto-clears after the
|
||||
* given duration.
|
||||
*/
|
||||
export class EmbeddedMpvFeedback {
|
||||
readonly current = signal<{
|
||||
icon: string;
|
||||
label: string;
|
||||
key: number;
|
||||
} | null>(null);
|
||||
|
||||
private timer: number | null = null;
|
||||
private nextKey = 0;
|
||||
|
||||
flash(icon: string, label: string, durationMs = 700): void {
|
||||
if (this.timer !== null) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.nextKey += 1;
|
||||
this.current.set({ icon, label, key: this.nextKey });
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.current.set(null);
|
||||
this.timer = null;
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.timer !== null) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user