Skip to content

Commit 2b1f5e5

Browse files
larsemigclaude
andcommitted
refactor(embedded-mpv): split session controller into focused collaborators
Mechanical decomposition of the embedded-MPV session controller into focused collaborators under embedded-mpv-player/: - embedded-mpv-command-runner.ts: transport/track/recording IPC delegators with guarded snapshot reconciliation - embedded-mpv-session-factory.ts: pure placeholder-session factories (loading/attaching/error) and the startup-paint wait - embedded-mpv-stalled-tracker.ts: loading-stall timer and stalled flag - embedded-mpv-compositor.ts: host bounds measurement (measureBounds), re-exported from embedded-mpv-format.utils for existing imports No behavior change. The existing embedded-mpv-player component is kept untouched and keeps working against the controller's unchanged public API (commands are now bound fields delegating to the runner). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9cbf902 commit 2b1f5e5

9 files changed

Lines changed: 469 additions & 285 deletions
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { Signal, WritableSignal } from '@angular/core';
2+
import { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
3+
4+
type ElectronBridge = Window['electron'];
5+
6+
/**
7+
* Context the {@link EmbeddedMpvCommandRunner} reads/writes. The controller owns
8+
* the signals; the runner only delegates IPC and reconciles the returned
9+
* snapshot back into `session`.
10+
*/
11+
export interface EmbeddedMpvCommandContext {
12+
readonly sessionId: Signal<string | null>;
13+
readonly session: WritableSignal<EmbeddedMpvSession | null>;
14+
}
15+
16+
/**
17+
* Thin IPC delegators for embedded-MPV transport/track/recording commands.
18+
* Split out of the controller so each stays a one-liner around `guardIpc`,
19+
* which swallows races where the session was torn down mid-call (the next
20+
* broadcast snapshot resyncs state).
21+
*/
22+
export class EmbeddedMpvCommandRunner {
23+
constructor(private readonly ctx: EmbeddedMpvCommandContext) {}
24+
25+
async togglePaused(): Promise<void> {
26+
const id = this.ctx.sessionId();
27+
const session = this.ctx.session();
28+
const electron = this.bridge();
29+
if (!id || !session || !electron?.setEmbeddedMpvPaused) {
30+
return;
31+
}
32+
await this.run(() =>
33+
electron.setEmbeddedMpvPaused(id, session.status !== 'paused')
34+
);
35+
}
36+
37+
async seekBy(deltaSeconds: number): Promise<boolean> {
38+
const id = this.ctx.sessionId();
39+
const session = this.ctx.session();
40+
const electron = this.bridge();
41+
if (!id || !session || !electron?.seekEmbeddedMpv) {
42+
return false;
43+
}
44+
const next = Math.max(0, session.positionSeconds + deltaSeconds);
45+
await this.run(() => electron.seekEmbeddedMpv(id, next));
46+
return true;
47+
}
48+
49+
async seekTo(seconds: number): Promise<void> {
50+
const id = this.ctx.sessionId();
51+
const electron = this.bridge();
52+
if (!id || !electron?.seekEmbeddedMpv) {
53+
return;
54+
}
55+
await this.run(() => electron.seekEmbeddedMpv(id, seconds));
56+
}
57+
58+
async applyVolume(value: number): Promise<void> {
59+
const id = this.ctx.sessionId();
60+
const electron = this.bridge();
61+
if (!id || !electron?.setEmbeddedMpvVolume) {
62+
return;
63+
}
64+
await this.run(() => electron.setEmbeddedMpvVolume(id, value));
65+
}
66+
67+
async setAudioTrack(trackId: number): Promise<void> {
68+
const id = this.ctx.sessionId();
69+
const electron = this.bridge();
70+
if (!id || !electron?.setEmbeddedMpvAudioTrack) {
71+
return;
72+
}
73+
await this.run(() => electron.setEmbeddedMpvAudioTrack(id, trackId));
74+
}
75+
76+
async setSubtitleTrack(trackId: number): Promise<void> {
77+
const id = this.ctx.sessionId();
78+
const electron = this.bridge();
79+
if (!id || !electron?.setEmbeddedMpvSubtitleTrack) {
80+
return;
81+
}
82+
const setSubtitleTrack = electron.setEmbeddedMpvSubtitleTrack;
83+
await this.run(() => setSubtitleTrack(id, trackId));
84+
}
85+
86+
async setSpeed(speed: number): Promise<void> {
87+
const id = this.ctx.sessionId();
88+
const electron = this.bridge();
89+
if (!id || !electron?.setEmbeddedMpvSpeed) {
90+
return;
91+
}
92+
const setSpeed = electron.setEmbeddedMpvSpeed;
93+
await this.run(() => setSpeed(id, speed));
94+
}
95+
96+
async setAspect(aspect: string): Promise<void> {
97+
const id = this.ctx.sessionId();
98+
const electron = this.bridge();
99+
if (!id || !electron?.setEmbeddedMpvAspect) {
100+
return;
101+
}
102+
const setAspect = electron.setEmbeddedMpvAspect;
103+
await this.run(() => setAspect(id, aspect));
104+
}
105+
106+
async startRecording(
107+
directory: string | undefined,
108+
title: string
109+
): Promise<EmbeddedMpvSession['recording'] | null> {
110+
const id = this.ctx.sessionId();
111+
const electron = this.bridge();
112+
if (!id || !electron?.startEmbeddedMpvRecording) {
113+
return null;
114+
}
115+
116+
const startEmbeddedMpvRecording = electron.startEmbeddedMpvRecording;
117+
const resolvedDirectory =
118+
directory?.trim() ||
119+
(await electron.getEmbeddedMpvDefaultRecordingFolder?.());
120+
const updated = await this.run(() =>
121+
startEmbeddedMpvRecording(id, {
122+
directory: resolvedDirectory,
123+
title,
124+
})
125+
);
126+
return updated?.recording ?? null;
127+
}
128+
129+
async stopRecording(): Promise<EmbeddedMpvSession['recording'] | null> {
130+
const id = this.ctx.sessionId();
131+
const electron = this.bridge();
132+
if (!id || !electron?.stopEmbeddedMpvRecording) {
133+
return null;
134+
}
135+
const stopEmbeddedMpvRecording = electron.stopEmbeddedMpvRecording;
136+
const updated = await this.run(() => stopEmbeddedMpvRecording(id));
137+
return updated?.recording ?? null;
138+
}
139+
140+
/**
141+
* Run an IPC call, reconcile the returned snapshot into `session`, and
142+
* return it (or null when the call was swallowed). Errors are intentionally
143+
* swallowed: the session may have been torn down or an addon-side throw may
144+
* have raced the IPC — the next snapshot resyncs state.
145+
*/
146+
private async run(
147+
call: () => Promise<EmbeddedMpvSession | null>
148+
): Promise<EmbeddedMpvSession | null> {
149+
const updated = await this.guardIpc(call);
150+
if (updated) {
151+
this.ctx.session.set(updated);
152+
}
153+
return updated;
154+
}
155+
156+
private async guardIpc<T>(call: () => Promise<T>): Promise<T | null> {
157+
try {
158+
return await call();
159+
} catch {
160+
return null;
161+
}
162+
}
163+
164+
private bridge(): ElectronBridge | undefined {
165+
return window.electron;
166+
}
167+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { measureBounds } from './embedded-mpv-compositor';
2+
3+
function createHost(rect: Partial<DOMRect>): HTMLElement {
4+
return {
5+
getBoundingClientRect: () =>
6+
({
7+
left: 0,
8+
top: 0,
9+
width: 800,
10+
height: 600,
11+
...rect,
12+
}) as DOMRect,
13+
} as HTMLElement;
14+
}
15+
16+
describe('embedded MPV compositor', () => {
17+
it('measures the full host viewport (always full-bleed)', () => {
18+
expect(measureBounds(createHost({ height: 600 }))).toEqual({
19+
x: 0,
20+
y: 0,
21+
width: 800,
22+
height: 600,
23+
});
24+
});
25+
26+
it('rounds host bounds and keeps minimum native view dimensions', () => {
27+
const host = createHost({
28+
left: 10.4,
29+
top: 20.6,
30+
width: 0,
31+
height: 0.2,
32+
});
33+
34+
expect(measureBounds(host)).toEqual({
35+
x: 10,
36+
y: 21,
37+
width: 1,
38+
height: 1,
39+
});
40+
});
41+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces';
2+
3+
/**
4+
* Measure the host's viewport rect as native-surface bounds.
5+
*
6+
* The native MPV surface composites over the WebContents and simply follows
7+
* the host viewport, so this single measurement is the default bounds
8+
* provider of the session controller. Hosts can override the provider (via
9+
* `setBoundsProvider`) when they need to inset or hide the surface.
10+
*/
11+
export function measureBounds(host: HTMLElement): EmbeddedMpvBounds {
12+
const rect = host.getBoundingClientRect();
13+
return {
14+
x: Math.round(rect.left),
15+
y: Math.round(rect.top),
16+
width: Math.max(1, Math.round(rect.width)),
17+
height: Math.max(1, Math.round(rect.height)),
18+
};
19+
}

libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,6 @@ export function persistVolume(value: number): void {
117117
localStorage.setItem('volume', String(value));
118118
}
119119

120-
export function measureBounds(host: HTMLElement): EmbeddedMpvBounds {
121-
const rect = host.getBoundingClientRect();
122-
return {
123-
x: Math.round(rect.left),
124-
y: Math.round(rect.top),
125-
width: Math.max(1, Math.round(rect.width)),
126-
height: Math.max(1, Math.round(rect.height)),
127-
};
128-
}
120+
// Kept as a re-export for existing imports; the implementation moved to the
121+
// compositor module alongside the session controller's bounds handling.
122+
export { measureBounds } from './embedded-mpv-compositor';

libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,27 @@ describe('EmbeddedMpvSessionController', () => {
167167
);
168168
});
169169

170+
it('attaches to an existing session and applies only matching broadcasts', async () => {
171+
const controller = TestBed.inject(EmbeddedMpvSessionController);
172+
173+
controller.attach('mpv-1');
174+
175+
expect(controller.sessionId()).toBe('mpv-1');
176+
// A loading placeholder is set until the first broadcast arrives.
177+
expect(controller.session()).toEqual(
178+
expect.objectContaining({ id: 'mpv-1', status: 'loading' })
179+
);
180+
// attach must not create or load a session.
181+
expect(electron.createEmbeddedMpvSession).not.toHaveBeenCalled();
182+
expect(electron.loadEmbeddedMpvPlayback).not.toHaveBeenCalled();
183+
184+
sessionUpdate?.(createSession({ id: 'other', status: 'paused' }));
185+
expect(controller.session()?.status).toBe('loading');
186+
187+
sessionUpdate?.(createSession({ id: 'mpv-1', status: 'playing' }));
188+
expect(controller.session()?.status).toBe('playing');
189+
});
190+
170191
it('sets an error session when Electron cannot create playback', async () => {
171192
electron.prepareEmbeddedMpv.mockRejectedValueOnce(
172193
new Error('native module missing')

0 commit comments

Comments
 (0)