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:
4grayandClaude Opus 4.7 committed 2026-05-05 10:09:31 +02:00
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;
}
}
}