refactor(embedded-mpv): split session controller into focused collaborators [2/7] (#1149)

* 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).

Test coverage extended per Codecov patch report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(embedded-mpv): drop superseded overlay hooks

* fix(embedded-mpv): guard async session races

* docs(embedded-mpv): document renderer collaborators

* fix(embedded-mpv): abort stale recording startup

* docs(embedded-mpv): clarify renderer safety details

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 4gray <serega05@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-07-16 14:13:13 +02:00
1 parent aa6ee85d3f
commit 8f597b44cf
11 files changed
+1179 -285

No files matched your search

+11 -5
View File
@@ -306,8 +306,11 @@ The Angular side of the embedded MPV player is intentionally split so the player
- `embedded-mpv-shortcuts.ts` — `EmbeddedMpvShortcuts` class with `attach(handlers)` / `detach()`. Owns the document keydown listener and routes through a callback interface; the component supplies the callbacks. Listens for Space/K (toggle), F (fullscreen), arrow keys (seek/volume), M (mute), Escape (close popovers).
- `embedded-mpv-overlay-visibility.service.ts` — singleton service that exposes `overlayActive: signal<boolean>`. Tracks `MatDialog.afterOpened`/`afterAllClosed` for dialog-shaped overlays and falls back to a `MutationObserver` on the CDK overlay container for any remaining backdrop-bearing CDK overlays. The native MPV video host is hidden off-screen while a modal is open so DOM dialogs can paint above it.
- `embedded-mpv-ui-state.ts` — `EmbeddedMpvMenuState` (single-open popover state machine with `volumeOpen`, `audioOpen`, `subtitleOpen`, `speedOpen`, `aspectOpen` signals plus `anyOpen` computed; `toggle`/`open`/`close`/`closeAll` helpers) and `EmbeddedMpvFeedback` (transient overlay that auto-clears after a configurable delay; used for keypress feedback).
- `embedded-mpv-session-controller.ts` — component-scoped `Injectable` service that owns the `support`, `session`, `sessionId`, `stalled`, and `retryToken` signals. Subscribes to `onEmbeddedMpvSessionUpdate`, runs the polling-driven `stalled` timer, owns bounds-sync (resize, scroll, overlay state), and exposes the imperative IPC surface (`startSession`, `togglePaused`, `seekBy`/`seekTo`, `applyVolume`, `setAudioTrack`, `setSubtitleTrack`, `setSpeed`, `setAspect`, `startRecording`, `stopRecording`, `retry`).
- `embedded-mpv-player.component.ts` — view-only shell. Holds view children, derived `computed` signals, DOM event listeners (pointermove, pointerdown, fullscreenchange, dblclick), and three `effect()`s.
- `embedded-mpv-command-runner.ts` — transport/track/recording IPC delegation; contains addon-side throws; reconciles a returned snapshot only when the current canonical session id and returned snapshot id both match the captured command session id.
- `embedded-mpv-session-factory.ts` — side-effect-free loading/error placeholder factories plus `waitForStartupPaint`.
- `embedded-mpv-stalled-tracker.ts` — owns the 30-second loading timer and `stalled` signal.
- `embedded-mpv-session-controller.ts` — component-scoped lifecycle coordinator. It exposes `support`, `session`, `sessionId`, `stalled`, and `retryToken`; subscribes to native updates; coordinates prepare/create/load/dispose, frame-copy attachment, and bounds sync; and delegates commands, placeholders, and stalled timing.
- `embedded-mpv-player.component.ts` — view-only shell. Holds view children, derived `computed` signals, DOM event listeners (pointermove, pointerdown, fullscreenchange, dblclick), and effects for session lifecycle, overlay/menu-driven bounds sync, session fan-out, playback-ended emission, and recording elapsed-time ticks.
### Bounds compositing strategy
@@ -332,15 +335,18 @@ Concrete bugs from the audit, recorded so they don't get reintroduced:
- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `loadSupport()` already populates it including capabilities.
- **Stream restart on volume change.** The session-creation effect once read `this.volume()` directly to pass to `startSession`'s `initialVolume`. Each volume tick re-ran the effect, disposing and recreating the session — for VOD/series this restarted playback from the beginning. Fix: read it via `untracked(() => this.volume())`. Subsequent volume changes flow through `controller.applyVolume()`, never through the effect graph.
- **Spurious `timeUpdate` re-emits and `volume.set` calls.** The session-fan-out effect calls `scheduleControlsHide()`, which reads `isPlaying`, `menus.anyOpen`, `statusLabel`, and `controlsVisible`. Those reads became tracked deps, so opening any popover, pausing, or hovering re-ran the body. No loop in isolation, but a parent that wires `timeUpdate` back into `playback.startTime` would have hit the volume-restart bug class. Fix: wrap the side-effect block in `untracked()` so the effect listens only to session changes.
- **2 Hz no-op stalled-tracker re-runs.** The controller's stalled effect tracked the full `session` signal, which updates on every position-poll snapshot. `handleStalledTracking` is a no-op for non-loading status, so the re-runs cost nothing useful. Fix: track a `sessionStatus = computed(() => this.session()?.status ?? null)` instead so the effect fires only on real status transitions.
- **2 Hz no-op stalled-tracker re-runs.** Position polling updates `session` around 2 Hz. Tracking the full session would re-run stalled logic for snapshots with unchanged status, so the controller tracks only `sessionStatus` and invokes `EmbeddedMpvStalledTracker.track` inside `untracked()`, avoiding full-session reruns.
When adding a new effect, audit it the same way: list every tracked signal read explicitly, justify each one as a _re-trigger source_, and wrap everything else in `untracked()`. When extending an existing helper that is called from inside an effect, treat the helper's signal reads as if they were inline in the effect.
### IPC safety
Renderer-side IPC methods on the controller use the canonical `sessionId()` signal as the gate, **not** `session()?.id`. The session payload during the loading window carries a placeholder id (`embedded-mpv-starting`) set by `createLoadingSession()`; pushing that placeholder to the addon would hit `getSessionOrThrow` for a session that does not exist. The native side throws `Napi::Error` rather than `std::runtime_error` so that misuse surfaces as a JS exception rather than a process abort, but the renderer should still gate properly so the addon never sees the placeholder.
Renderer command IPC in `EmbeddedMpvCommandRunner` uses the canonical `sessionId()` signal as the gate, **not** `session()?.id`. The session payload during the loading window carries a placeholder id (`embedded-mpv-starting`) set by `createLoadingSession()`; pushing that placeholder to the addon would hit `getSessionOrThrow` for a session that does not exist. The native side throws `Napi::Error` rather than `std::runtime_error` so that misuse surfaces as a JS exception rather than a process abort, but the renderer should still gate properly so the addon never sees the placeholder.
Every IPC call goes through a `guardIpc` helper that swallows addon-side throws — sessions can be torn down while a call is in flight, and snapshot polling will resync state on the next tick.
- **Playback-load teardown.** If teardown happens while `loadEmbeddedMpvPlayback` is in flight, the asynchronous startup task exits immediately after the load resolves, before frame attachment or bounds scheduling. The teardown path owns disposal of that session.
- **Command identity.** Renderer commands capture the canonical `sessionId` before starting IPC. Default recording-folder resolution is asynchronous preflight before the recording-command IPC. The runner revalidates the captured id immediately after that preflight and skips IPC when it no longer matches, preventing a late recording command from being issued against the superseded session.
- **Reply reconciliation.** `EmbeddedMpvCommandRunner` applies a returned snapshot only when both the current canonical `sessionId` and the returned snapshot id match the captured command session id. Late or mismatched replies are ignored. Its `guardIpc` helper contains addon-side errors, and the next broadcast session update resynchronizes state.
- **Frame-attachment teardown.** If teardown happens during asynchronous frame attachment, the asynchronous startup task exits immediately after the attachment await, before bounds scheduling. The preload detach/attachment epochs abort pending frame setup, so the teardown's detach is not followed by a second late global detach.
### Power management
@@ -0,0 +1,286 @@
import { signal } from '@angular/core';
import { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner';
function createSession(
overrides: Partial<EmbeddedMpvSession> = {}
): EmbeddedMpvSession {
return {
id: 'mpv-1',
title: 'Example Movie',
streamUrl: 'https://example.com/movie.mp4',
status: 'playing',
positionSeconds: 10,
durationSeconds: 120,
volume: 0.7,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-06-02T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:01.000Z',
...overrides,
};
}
describe('EmbeddedMpvCommandRunner', () => {
const sessionId = signal<string | null>('mpv-1');
const session = signal<EmbeddedMpvSession | null>(createSession());
let runner: EmbeddedMpvCommandRunner;
let electron: Record<string, jest.Mock>;
const setBridge = (value: unknown) =>
Object.defineProperty(window, 'electron', {
configurable: true,
value,
});
beforeEach(() => {
sessionId.set('mpv-1');
session.set(createSession());
electron = {
setEmbeddedMpvPaused: jest
.fn()
.mockResolvedValue(createSession({ status: 'paused' })),
seekEmbeddedMpv: jest
.fn()
.mockResolvedValue(createSession({ positionSeconds: 42 })),
setEmbeddedMpvVolume: jest
.fn()
.mockResolvedValue(createSession({ volume: 0.3 })),
setEmbeddedMpvAudioTrack: jest.fn().mockResolvedValue(null),
setEmbeddedMpvSubtitleTrack: jest.fn().mockResolvedValue(null),
setEmbeddedMpvSpeed: jest
.fn()
.mockResolvedValue(createSession({ playbackSpeed: 1.5 })),
setEmbeddedMpvAspect: jest
.fn()
.mockResolvedValue(createSession({ aspectOverride: '16:9' })),
startEmbeddedMpvRecording: jest.fn().mockResolvedValue(
createSession({
recording: { active: true, targetPath: '/tmp/rec.ts' },
})
),
stopEmbeddedMpvRecording: jest.fn().mockResolvedValue(
createSession({
recording: { active: false, targetPath: '/tmp/rec.ts' },
})
),
getEmbeddedMpvDefaultRecordingFolder: jest
.fn()
.mockResolvedValue('/movies/recordings'),
};
setBridge(electron);
runner = new EmbeddedMpvCommandRunner({ sessionId, session });
});
afterEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
});
it('togglePaused flips based on the current status and reconciles the snapshot', async () => {
await runner.togglePaused();
expect(electron.setEmbeddedMpvPaused).toHaveBeenCalledWith(
'mpv-1',
true
);
expect(session()?.status).toBe('paused');
await runner.togglePaused();
// Now paused → resume.
expect(electron.setEmbeddedMpvPaused).toHaveBeenLastCalledWith(
'mpv-1',
false
);
});
it('guards commands when no session id is set', async () => {
sessionId.set(null);
await runner.togglePaused();
expect(await runner.seekBy(10)).toBe(false);
await runner.seekTo(5);
await runner.applyVolume(0.5);
await runner.setSpeed(2);
expect(await runner.startRecording(undefined, 'Title')).toBeNull();
expect(await runner.stopRecording()).toBeNull();
expect(electron.setEmbeddedMpvPaused).not.toHaveBeenCalled();
expect(electron.seekEmbeddedMpv).not.toHaveBeenCalled();
});
it('guards session-dependent commands when the session snapshot is missing', async () => {
session.set(null);
await runner.togglePaused();
expect(await runner.seekBy(10)).toBe(false);
expect(electron.setEmbeddedMpvPaused).not.toHaveBeenCalled();
expect(electron.seekEmbeddedMpv).not.toHaveBeenCalled();
});
it('guards every command when the bridge method is unavailable', async () => {
setBridge({});
await runner.togglePaused();
expect(await runner.seekBy(5)).toBe(false);
await runner.seekTo(5);
await runner.applyVolume(0.4);
await runner.setAudioTrack(1);
await runner.setSubtitleTrack(2);
await runner.setSpeed(1.25);
await runner.setAspect('4:3');
expect(await runner.startRecording('/tmp', 'Title')).toBeNull();
expect(await runner.stopRecording()).toBeNull();
expect(session()?.status).toBe('playing');
});
it('seekTo seeks to an absolute position and reconciles the snapshot', async () => {
await runner.seekTo(42);
expect(electron.seekEmbeddedMpv).toHaveBeenCalledWith('mpv-1', 42);
expect(session()?.positionSeconds).toBe(42);
});
it('seekBy clamps to zero and reports that it ran', async () => {
expect(await runner.seekBy(-999)).toBe(true);
expect(electron.seekEmbeddedMpv).toHaveBeenCalledWith('mpv-1', 0);
expect(session()?.positionSeconds).toBe(42);
});
it('delegates track/speed/aspect commands and keeps state on null snapshots', async () => {
const before = session();
await runner.setAudioTrack(3);
await runner.setSubtitleTrack(-1);
expect(electron.setEmbeddedMpvAudioTrack).toHaveBeenCalledWith(
'mpv-1',
3
);
expect(electron.setEmbeddedMpvSubtitleTrack).toHaveBeenCalledWith(
'mpv-1',
-1
);
// Null snapshots must not clear the current session.
expect(session()).toBe(before);
await runner.setSpeed(1.5);
expect(session()?.playbackSpeed).toBe(1.5);
await runner.setAspect('16:9');
expect(session()?.aspectOverride).toBe('16:9');
});
it('swallows IPC errors and leaves the session untouched', async () => {
const current = session();
electron.seekEmbeddedMpv.mockRejectedValueOnce(
new Error('session disposed')
);
expect(await runner.seekBy(10)).toBe(true);
expect(session()).toBe(current);
electron.setEmbeddedMpvVolume.mockRejectedValueOnce(new Error('gone'));
await expect(runner.applyVolume(0.2)).resolves.toBeUndefined();
expect(session()).toBe(current);
});
it('ignores a command reply after the active session is replaced', async () => {
let resolveCommand:
| ((value: EmbeddedMpvSession | null) => void)
| null = null;
electron.seekEmbeddedMpv.mockImplementationOnce(
() =>
new Promise<EmbeddedMpvSession | null>((resolve) => {
resolveCommand = resolve;
})
);
const pendingCommand = runner.seekTo(42);
const replacement = createSession({
id: 'mpv-2',
title: 'Replacement Movie',
});
sessionId.set('mpv-2');
session.set(replacement);
resolveCommand?.(createSession({ id: 'mpv-1', positionSeconds: 42 }));
await pendingCommand;
expect(session()).toBe(replacement);
});
it('startRecording resolves the default folder for blank directories', async () => {
const recording = await runner.startRecording(' ', 'My Show');
expect(
electron.getEmbeddedMpvDefaultRecordingFolder
).toHaveBeenCalled();
expect(electron.startEmbeddedMpvRecording).toHaveBeenCalledWith(
'mpv-1',
{ directory: '/movies/recordings', title: 'My Show' }
);
expect(recording).toEqual({ active: true, targetPath: '/tmp/rec.ts' });
expect(session()?.recording?.active).toBe(true);
});
it('does not start recording after the session changes during folder lookup', async () => {
let resolveFolder: ((folder: string) => void) | null = null;
electron.getEmbeddedMpvDefaultRecordingFolder.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveFolder = resolve;
})
);
const pendingRecording = runner.startRecording(' ', 'My Show');
expect(
electron.getEmbeddedMpvDefaultRecordingFolder
).toHaveBeenCalled();
const replacement = createSession({
id: 'mpv-2',
title: 'Replacement Movie',
});
sessionId.set('mpv-2');
session.set(replacement);
resolveFolder?.('/movies/recordings');
const recording = await pendingRecording;
expect(electron.startEmbeddedMpvRecording).not.toHaveBeenCalled();
expect(recording).toBeNull();
expect(session()).toBe(replacement);
});
it('startRecording uses the explicit directory when provided', async () => {
await runner.startRecording('/custom/dir', 'My Show');
expect(
electron.getEmbeddedMpvDefaultRecordingFolder
).not.toHaveBeenCalled();
expect(electron.startEmbeddedMpvRecording).toHaveBeenCalledWith(
'mpv-1',
{ directory: '/custom/dir', title: 'My Show' }
);
});
it('startRecording returns null when the IPC call fails', async () => {
electron.startEmbeddedMpvRecording.mockRejectedValueOnce(
new Error('no disk')
);
expect(await runner.startRecording('/custom', 'Title')).toBeNull();
});
it('ignores a recording reply whose session id does not match the request', async () => {
const current = session();
electron.startEmbeddedMpvRecording.mockResolvedValueOnce(
createSession({
id: 'mpv-mismatch',
recording: { active: true, targetPath: '/tmp/rec.ts' },
})
);
const recording = await runner.startRecording('/custom', 'Title');
expect(recording).toBeNull();
expect(session()).toBe(current);
});
it('stopRecording returns the reconciled recording state', async () => {
const recording = await runner.stopRecording();
expect(electron.stopEmbeddedMpvRecording).toHaveBeenCalledWith('mpv-1');
expect(recording).toEqual({ active: false, targetPath: '/tmp/rec.ts' });
expect(session()?.recording).toEqual(recording);
});
});
@@ -0,0 +1,178 @@
import { Signal, WritableSignal } from '@angular/core';
import { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
type ElectronBridge = Window['electron'];
/**
* Context the {@link EmbeddedMpvCommandRunner} reads/writes. The controller owns
* the signals; the runner only delegates IPC and reconciles the returned
* snapshot back into `session`.
*/
export interface EmbeddedMpvCommandContext {
readonly sessionId: Signal<string | null>;
readonly session: WritableSignal<EmbeddedMpvSession | null>;
}
/**
* Thin IPC delegators for embedded-MPV transport/track/recording commands.
* Split out of the controller so each stays a one-liner around `guardIpc`,
* which swallows races where the session was torn down mid-call (the next
* broadcast snapshot resyncs state).
*/
export class EmbeddedMpvCommandRunner {
constructor(private readonly ctx: EmbeddedMpvCommandContext) {}
async togglePaused(): Promise<void> {
const id = this.ctx.sessionId();
const session = this.ctx.session();
const electron = this.bridge();
if (!id || !session || !electron?.setEmbeddedMpvPaused) {
return;
}
await this.run(id, () =>
electron.setEmbeddedMpvPaused(id, session.status !== 'paused')
);
}
async seekBy(deltaSeconds: number): Promise<boolean> {
const id = this.ctx.sessionId();
const session = this.ctx.session();
const electron = this.bridge();
if (!id || !session || !electron?.seekEmbeddedMpv) {
return false;
}
const next = Math.max(0, session.positionSeconds + deltaSeconds);
await this.run(id, () => electron.seekEmbeddedMpv(id, next));
return true;
}
async seekTo(seconds: number): Promise<void> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.seekEmbeddedMpv) {
return;
}
await this.run(id, () => electron.seekEmbeddedMpv(id, seconds));
}
async applyVolume(value: number): Promise<void> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.setEmbeddedMpvVolume) {
return;
}
await this.run(id, () => electron.setEmbeddedMpvVolume(id, value));
}
async setAudioTrack(trackId: number): Promise<void> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.setEmbeddedMpvAudioTrack) {
return;
}
await this.run(id, () =>
electron.setEmbeddedMpvAudioTrack(id, trackId)
);
}
async setSubtitleTrack(trackId: number): Promise<void> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.setEmbeddedMpvSubtitleTrack) {
return;
}
const setSubtitleTrack = electron.setEmbeddedMpvSubtitleTrack;
await this.run(id, () => setSubtitleTrack(id, trackId));
}
async setSpeed(speed: number): Promise<void> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.setEmbeddedMpvSpeed) {
return;
}
const setSpeed = electron.setEmbeddedMpvSpeed;
await this.run(id, () => setSpeed(id, speed));
}
async setAspect(aspect: string): Promise<void> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.setEmbeddedMpvAspect) {
return;
}
const setAspect = electron.setEmbeddedMpvAspect;
await this.run(id, () => setAspect(id, aspect));
}
async startRecording(
directory: string | undefined,
title: string
): Promise<EmbeddedMpvSession['recording'] | null> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.startEmbeddedMpvRecording) {
return null;
}
const startEmbeddedMpvRecording = electron.startEmbeddedMpvRecording;
const resolvedDirectory =
directory?.trim() ||
(await electron.getEmbeddedMpvDefaultRecordingFolder?.());
if (this.ctx.sessionId() !== id) {
return null;
}
const updated = await this.run(id, () =>
startEmbeddedMpvRecording(id, {
directory: resolvedDirectory,
title,
})
);
return updated?.recording ?? null;
}
async stopRecording(): Promise<EmbeddedMpvSession['recording'] | null> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.stopEmbeddedMpvRecording) {
return null;
}
const stopEmbeddedMpvRecording = electron.stopEmbeddedMpvRecording;
const updated = await this.run(id, () => stopEmbeddedMpvRecording(id));
return updated?.recording ?? null;
}
/**
* Run an IPC call, reconcile the returned snapshot into `session`, and
* return it (or null when the call was swallowed). Errors are intentionally
* swallowed: the session may have been torn down or an addon-side throw may
* have raced the IPC — the next snapshot resyncs state.
*/
private async run(
expectedSessionId: string,
call: () => Promise<EmbeddedMpvSession | null>
): Promise<EmbeddedMpvSession | null> {
const updated = await this.guardIpc(call);
if (
!updated ||
this.ctx.sessionId() !== expectedSessionId ||
updated.id !== expectedSessionId
) {
return null;
}
this.ctx.session.set(updated);
return updated;
}
private async guardIpc<T>(call: () => Promise<T>): Promise<T | null> {
try {
return await call();
} catch {
return null;
}
}
private bridge(): ElectronBridge | undefined {
return window.electron;
}
}
@@ -1,4 +1,7 @@
import { EmbeddedMpvAudioTrack, EmbeddedMpvBounds } from '@iptvnator/shared/interfaces';
import {
EmbeddedMpvAudioTrack,
EmbeddedMpvBounds,
} from '@iptvnator/shared/interfaces';
export const HIDDEN_BOUNDS: EmbeddedMpvBounds = Object.freeze({
x: -100000,
@@ -0,0 +1,393 @@
import { TestBed } from '@angular/core/testing';
import {
EmbeddedMpvEngine,
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
let electron: {
platform: string;
getEmbeddedMpvSupport: jest.Mock;
prepareEmbeddedMpv: jest.Mock;
createEmbeddedMpvSession: jest.Mock;
loadEmbeddedMpvPlayback: jest.Mock;
attachEmbeddedMpvFrameView: jest.Mock;
detachEmbeddedMpvFrameView: jest.Mock;
disposeEmbeddedMpvSession: jest.Mock;
setEmbeddedMpvBounds: jest.Mock;
onEmbeddedMpvSessionUpdate: jest.Mock;
};
const setBridge = (value: unknown) =>
Object.defineProperty(window, 'electron', {
configurable: true,
value,
});
beforeEach(() => {
electron = {
platform: 'darwin',
getEmbeddedMpvSupport: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'darwin' }),
prepareEmbeddedMpv: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'darwin' }),
createEmbeddedMpvSession: jest
.fn()
.mockResolvedValue(createSession({ id: 'mpv-1' })),
loadEmbeddedMpvPlayback: jest.fn().mockResolvedValue(undefined),
attachEmbeddedMpvFrameView: jest.fn().mockResolvedValue(true),
detachEmbeddedMpvFrameView: jest.fn(),
disposeEmbeddedMpvSession: jest.fn().mockResolvedValue(undefined),
setEmbeddedMpvBounds: jest.fn().mockResolvedValue(undefined),
onEmbeddedMpvSessionUpdate: jest.fn(() => jest.fn()),
};
setBridge(electron);
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: class MockResizeObserver {
observe = jest.fn();
disconnect = jest.fn();
},
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: (handle: number) => window.clearTimeout(handle),
});
TestBed.configureTestingModule({
providers: [EmbeddedMpvSessionController],
});
});
afterEach(() => {
TestBed.resetTestingModule();
delete (window as unknown as { electron?: unknown }).electron;
jest.useRealTimers();
jest.restoreAllMocks();
});
it('reports unsupported outside the Electron desktop build', () => {
setBridge(undefined);
const controller = TestBed.inject(EmbeddedMpvSessionController);
expect(controller.support()).toEqual({
supported: false,
platform: 'unknown',
reason: 'Embedded MPV requires the Electron desktop build.',
});
});
it('maps a support-probe failure onto an unsupported result', async () => {
electron.getEmbeddedMpvSupport.mockRejectedValueOnce(
new Error('addon load failed')
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() !== null,
'support fallback to be set'
);
expect(controller.support()).toEqual({
supported: false,
platform: 'darwin',
reason: 'addon load failed',
});
});
it('preserves the constructor support probe after preparing a session', async () => {
const probedSupport = createSupport('native');
const preparedSupport = createSupport('frame-copy');
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(probedSupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(preparedSupport);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() === probedSupport,
'constructor support probe to resolve'
);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
try {
await waitFor(
() => electron.loadEmbeddedMpvPlayback.mock.calls.length > 0,
'playback load to start'
);
expect(controller.support()).toBe(probedSupport);
expect(electron.createEmbeddedMpvSession).toHaveBeenCalled();
expect(electron.loadEmbeddedMpvPlayback).toHaveBeenCalled();
} finally {
teardown();
}
});
it('sets an error session when prepare reports unsupported', async () => {
electron.prepareEmbeddedMpv.mockResolvedValueOnce({
supported: false,
platform: 'darwin',
reason: 'libmpv not found',
});
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.startSession(createHost(), createPlayback(), 0.5);
await waitFor(
() => controller.session()?.status === 'error',
'error session to be set'
);
expect(controller.session()?.error).toBe('libmpv not found');
expect(electron.createEmbeddedMpvSession).not.toHaveBeenCalled();
});
it('retry clears session state, stall flag, and bumps the retry token', () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.sessionId.set('mpv-1');
controller.session.set(createSession({ status: 'error' }));
controller.retry();
expect(controller.session()).toBeNull();
expect(controller.sessionId()).toBeNull();
expect(controller.stalled()).toBe(false);
expect(controller.retryToken()).toBe(1);
});
it('flags a stalled session after 30s of loading and clears it on playback', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
jest.useFakeTimers();
controller.session.set(createSession({ status: 'loading' }));
TestBed.tick();
jest.advanceTimersByTime(30_000);
expect(controller.stalled()).toBe(true);
controller.session.set(createSession({ status: 'playing' }));
TestBed.tick();
expect(controller.stalled()).toBe(false);
});
it('disposes a session whose creation resolves only after teardown', async () => {
let resolveCreate: ((session: EmbeddedMpvSession) => void) | null =
null;
electron.createEmbeddedMpvSession.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
})
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => resolveCreate !== null,
'startup to reach createEmbeddedMpvSession'
);
teardown();
resolveCreate?.(createSession({ id: 'mpv-late' }));
await waitFor(
() => electron.disposeEmbeddedMpvSession.mock.calls.length > 0,
'late session to be disposed'
);
expect(electron.disposeEmbeddedMpvSession).toHaveBeenCalledWith(
'mpv-late'
);
expect(controller.sessionId()).toBeNull();
expect(electron.loadEmbeddedMpvPlayback).not.toHaveBeenCalled();
});
it('does not continue frame setup when teardown happens during playback load', async () => {
const frameCopySupport = createSupport('frame-copy');
let resolveLoad: (() => void) | null = null;
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport);
electron.loadEmbeddedMpvPlayback.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveLoad = resolve;
})
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() === frameCopySupport,
'frame-copy support probe to resolve'
);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => electron.loadEmbeddedMpvPlayback.mock.calls.length > 0,
'playback load to start'
);
teardown();
resolveLoad?.();
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(electron.attachEmbeddedMpvFrameView).not.toHaveBeenCalled();
expect(controller.session()).toBeNull();
expect(controller.sessionId()).toBeNull();
expect(electron.disposeEmbeddedMpvSession).toHaveBeenCalledWith(
'mpv-1'
);
expect(electron.detachEmbeddedMpvFrameView).toHaveBeenCalled();
});
it('does not schedule bounds after teardown during frame view attachment', async () => {
const frameCopySupport = createSupport('frame-copy');
let resolveAttach: ((attached: boolean) => void) | null = null;
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport);
electron.attachEmbeddedMpvFrameView.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
resolveAttach = resolve;
})
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() === frameCopySupport,
'frame-copy support probe to resolve'
);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => electron.attachEmbeddedMpvFrameView.mock.calls.length > 0,
'frame view attachment to start'
);
teardown();
const requestAnimationFrame = jest.spyOn(
window,
'requestAnimationFrame'
);
resolveAttach?.(true);
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(requestAnimationFrame).not.toHaveBeenCalled();
expect(controller.session()).toBeNull();
expect(controller.sessionId()).toBeNull();
expect(electron.disposeEmbeddedMpvSession).toHaveBeenCalledWith(
'mpv-1'
);
expect(electron.detachEmbeddedMpvFrameView).toHaveBeenCalledTimes(1);
});
it('syncs bounds through a custom provider on triggerBoundsSync', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.setBoundsProvider(() => ({
x: 1,
y: 2,
width: 300,
height: 200,
}));
controller.startSession(createHost(), createPlayback(), 0.5);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
electron.setEmbeddedMpvBounds.mockClear();
controller.triggerBoundsSync();
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'bounds sync to run'
);
expect(electron.setEmbeddedMpvBounds).toHaveBeenCalledWith('mpv-1', {
x: 1,
y: 2,
width: 300,
height: 200,
});
});
});
function createHost(): HTMLElement {
return {
getBoundingClientRect: () => ({
left: 10,
top: 20,
width: 640,
height: 360,
}),
} as HTMLElement;
}
function createPlayback(): ResolvedPortalPlayback {
return {
streamUrl: 'https://example.com/movie.mp4',
title: 'Example Movie',
};
}
function createSupport(engine: EmbeddedMpvEngine) {
return { supported: true, platform: 'darwin', engine };
}
function createSession(
overrides: Partial<EmbeddedMpvSession> = {}
): EmbeddedMpvSession {
return {
id: 'mpv-1',
title: 'Example Movie',
streamUrl: 'https://example.com/movie.mp4',
status: 'playing',
positionSeconds: 10,
durationSeconds: 120,
volume: 0.7,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-06-02T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:01.000Z',
...overrides,
};
}
async function waitFor(
condition: () => boolean,
description: string
): Promise<void> {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
if (condition()) {
return;
}
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
throw new Error(`Timed out waiting for ${description}`);
}
@@ -222,6 +222,58 @@ describe('EmbeddedMpvSessionController', () => {
expect(controller.sessionId()).toBe('mpv-2');
});
it('delegates track, speed, aspect, and recording commands to the runner', async () => {
const commandBridge = {
setEmbeddedMpvAudioTrack: jest.fn().mockResolvedValue(null),
setEmbeddedMpvSubtitleTrack: jest.fn().mockResolvedValue(null),
setEmbeddedMpvSpeed: jest.fn().mockResolvedValue(null),
setEmbeddedMpvAspect: jest.fn().mockResolvedValue(null),
seekEmbeddedMpv: jest.fn().mockResolvedValue(null),
startEmbeddedMpvRecording: jest.fn().mockResolvedValue(
createSession({
recording: { active: true, targetPath: '/tmp/rec.ts' },
})
),
stopEmbeddedMpvRecording: jest
.fn()
.mockResolvedValue(
createSession({ recording: { active: false } })
),
};
Object.assign(electron, commandBridge);
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.sessionId.set('mpv-1');
controller.session.set(createSession());
await controller.seekTo(75);
await controller.setAudioTrack(2);
await controller.setSubtitleTrack(-1);
await controller.setSpeed(1.5);
await controller.setAspect('16:9');
const started = await controller.startRecording('/rec', 'Show');
const stopped = await controller.stopRecording();
expect(commandBridge.seekEmbeddedMpv).toHaveBeenCalledWith('mpv-1', 75);
expect(commandBridge.setEmbeddedMpvAudioTrack).toHaveBeenCalledWith(
'mpv-1',
2
);
expect(commandBridge.setEmbeddedMpvSubtitleTrack).toHaveBeenCalledWith(
'mpv-1',
-1
);
expect(commandBridge.setEmbeddedMpvSpeed).toHaveBeenCalledWith(
'mpv-1',
1.5
);
expect(commandBridge.setEmbeddedMpvAspect).toHaveBeenCalledWith(
'mpv-1',
'16:9'
);
expect(started).toEqual({ active: true, targetPath: '/tmp/rec.ts' });
expect(stopped).toEqual({ active: false });
});
it('forwards playback commands and updates session snapshots', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.sessionId.set('mpv-1');
@@ -13,14 +13,19 @@ import {
EmbeddedMpvSupport,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner';
import { measureBounds } from './embedded-mpv-format.utils';
import {
createErrorSession,
createLoadingSession,
waitForStartupPaint,
} from './embedded-mpv-session-factory';
import { EmbeddedMpvStalledTracker } from './embedded-mpv-stalled-tracker';
export type EmbeddedMpvBoundsProvider = (
host: HTMLElement
) => EmbeddedMpvBounds;
const STALLED_TIMEOUT_MS = 30_000;
type ElectronBridge = Window['electron'];
@Injectable()
@@ -28,9 +33,16 @@ 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 stalledTracker = new EmbeddedMpvStalledTracker();
readonly stalled = this.stalledTracker.stalled;
private readonly commands = new EmbeddedMpvCommandRunner({
sessionId: this.sessionId,
session: this.session,
});
readonly isFrameCopyEngine = computed(
() => this.support()?.engine === 'frame-copy'
);
@@ -46,7 +58,6 @@ export class EmbeddedMpvSessionController {
measureBounds(host);
private activeBoundsSync: (() => void) | null = null;
private boundsAnimationFrame: number | null = null;
private stalledTimer: number | null = null;
constructor() {
this.unsubscribeSessionUpdate =
@@ -67,17 +78,16 @@ export class EmbeddedMpvSessionController {
});
}
// Track the narrowest possible signal — status only — so this effect
// does not re-run on every position-poll snapshot (~2 Hz during play)
// even though handleStalledTracking would be a no-op for those.
// Track status only so this effect does not re-run on every
// position-poll snapshot (~2 Hz) where stalled tracking is a no-op.
effect(() => {
const status = this.sessionStatus();
untracked(() => this.handleStalledTracking(status));
untracked(() => this.stalledTracker.track(status));
});
this.destroyRef.onDestroy(() => {
this.unsubscribeSessionUpdate?.();
this.cancelStalledTimer();
this.stalledTracker.cancel();
if (this.boundsAnimationFrame !== null) {
cancelAnimationFrame(this.boundsAnimationFrame);
this.boundsAnimationFrame = null;
@@ -94,17 +104,15 @@ export class EmbeddedMpvSessionController {
}
retry(): void {
this.stalled.set(false);
this.stalledTracker.reset();
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.
* Spin up an embedded MPV session bound to `host`. Returns a teardown the
* caller invokes when host/playback changes or the component tears down.
*/
startSession(
host: HTMLElement,
@@ -118,9 +126,11 @@ export class EmbeddedMpvSessionController {
if (!activeSessionId) {
return;
}
const bounds = this.boundsProvider(host);
void window.electron
?.setEmbeddedMpvBounds(activeSessionId, bounds)
?.setEmbeddedMpvBounds(
activeSessionId,
this.boundsProvider(host)
)
.catch(() => undefined);
};
@@ -142,10 +152,8 @@ export class EmbeddedMpvSessionController {
window.addEventListener('scroll', scheduleBoundsSync, true);
const create = async () => {
this.session.set(
this.createLoadingSession(playback, initialVolume)
);
await this.waitForStartupPaint();
this.session.set(createLoadingSession(playback, initialVolume));
await waitForStartupPaint();
if (disposed) {
return;
}
@@ -167,9 +175,6 @@ export class EmbeddedMpvSessionController {
'Embedded MPV is not available in this environment.'
);
}
if (prepared?.supported) {
this.support.set(prepared);
}
const created = await electron.createEmbeddedMpvSession(
measureBounds(host),
@@ -186,6 +191,9 @@ export class EmbeddedMpvSessionController {
this.sessionId.set(created.id);
this.session.set(created);
await electron.loadEmbeddedMpvPlayback(created.id, playback);
if (disposed) {
return;
}
if (untracked(() => this.isFrameCopyEngine())) {
// Frame-copy engine: start the preload frame pump that
// paints helper frames onto the component's canvas. A failed
@@ -195,6 +203,9 @@ export class EmbeddedMpvSessionController {
const attached = await electron
.attachEmbeddedMpvFrameView?.(created.id)
.catch(() => false);
if (disposed) {
return;
}
if (attached === false && !disposed) {
await electron
.disposeEmbeddedMpvSession(created.id)
@@ -214,8 +225,10 @@ export class EmbeddedMpvSessionController {
if (disposed) {
return;
}
// Factory is pure; clear sessionId here (controller owns mutation).
this.sessionId.set(null);
this.session.set(
this.createErrorSession(playback, initialVolume, error)
createErrorSession(playback, initialVolume, error)
);
});
@@ -247,172 +260,31 @@ export class EmbeddedMpvSessionController {
};
}
async togglePaused(): Promise<void> {
const id = this.sessionId();
const session = this.session();
const electron = this.getElectronBridge();
if (!id || !session || !electron?.setEmbeddedMpvPaused) {
return;
}
const updated = await this.guardIpc(() =>
electron.setEmbeddedMpvPaused(id, session.status !== 'paused')
);
if (updated) {
this.session.set(updated);
}
}
async seekBy(deltaSeconds: number): Promise<boolean> {
const id = this.sessionId();
const session = this.session();
const electron = this.getElectronBridge();
if (!id || !session || !electron?.seekEmbeddedMpv) {
return false;
}
const next = Math.max(0, session.positionSeconds + deltaSeconds);
const updated = await this.guardIpc(() =>
electron.seekEmbeddedMpv(id, next)
);
if (updated) {
this.session.set(updated);
}
return true;
}
async seekTo(seconds: number): Promise<void> {
const id = this.sessionId();
const electron = this.getElectronBridge();
if (!id || !electron?.seekEmbeddedMpv) {
return;
}
const updated = await this.guardIpc(() =>
electron.seekEmbeddedMpv(id, seconds)
);
if (updated) {
this.session.set(updated);
}
}
async applyVolume(value: number): Promise<void> {
const id = this.sessionId();
const electron = this.getElectronBridge();
if (!id || !electron?.setEmbeddedMpvVolume) {
return;
}
const updated = await this.guardIpc(() =>
electron.setEmbeddedMpvVolume(id, value)
);
if (updated) {
this.session.set(updated);
}
}
async setAudioTrack(trackId: number): Promise<void> {
const id = this.sessionId();
const electron = this.getElectronBridge();
if (!id || !electron?.setEmbeddedMpvAudioTrack) {
return;
}
const updated = await this.guardIpc(() =>
electron.setEmbeddedMpvAudioTrack(id, trackId)
);
if (updated) {
this.session.set(updated);
}
}
async setSubtitleTrack(trackId: number): Promise<void> {
const id = this.sessionId();
const electron = this.getElectronBridge();
if (!id || !electron?.setEmbeddedMpvSubtitleTrack) {
return;
}
const setSubtitleTrack = electron.setEmbeddedMpvSubtitleTrack;
const updated = await this.guardIpc(() =>
setSubtitleTrack(id, trackId)
);
if (updated) {
this.session.set(updated);
}
}
async setSpeed(speed: number): Promise<void> {
const id = this.sessionId();
const electron = this.getElectronBridge();
if (!id || !electron?.setEmbeddedMpvSpeed) {
return;
}
const setSpeed = electron.setEmbeddedMpvSpeed;
const updated = await this.guardIpc(() => setSpeed(id, speed));
if (updated) {
this.session.set(updated);
}
}
async setAspect(aspect: string): Promise<void> {
const id = this.sessionId();
const electron = this.getElectronBridge();
if (!id || !electron?.setEmbeddedMpvAspect) {
return;
}
const setAspect = electron.setEmbeddedMpvAspect;
const updated = await this.guardIpc(() => setAspect(id, aspect));
if (updated) {
this.session.set(updated);
}
}
async startRecording(
// Transport/track/recording commands delegate to the command runner (owns
// IPC + snapshot reconciliation). Bound fields keep the public API stable.
readonly togglePaused = (): Promise<void> => this.commands.togglePaused();
readonly seekBy = (deltaSeconds: number): Promise<boolean> =>
this.commands.seekBy(deltaSeconds);
readonly seekTo = (seconds: number): Promise<void> =>
this.commands.seekTo(seconds);
readonly applyVolume = (value: number): Promise<void> =>
this.commands.applyVolume(value);
readonly setAudioTrack = (trackId: number): Promise<void> =>
this.commands.setAudioTrack(trackId);
readonly setSubtitleTrack = (trackId: number): Promise<void> =>
this.commands.setSubtitleTrack(trackId);
readonly setSpeed = (speed: number): Promise<void> =>
this.commands.setSpeed(speed);
readonly setAspect = (aspect: string): Promise<void> =>
this.commands.setAspect(aspect);
readonly startRecording = (
directory: string | undefined,
title: string
): Promise<EmbeddedMpvSession['recording'] | null> {
const id = this.sessionId();
const electron = this.getElectronBridge();
if (!id || !electron?.startEmbeddedMpvRecording) {
return null;
}
const startEmbeddedMpvRecording = electron.startEmbeddedMpvRecording;
const resolvedDirectory =
directory?.trim() ||
(await electron.getEmbeddedMpvDefaultRecordingFolder?.());
const updated = await this.guardIpc(() =>
startEmbeddedMpvRecording(id, {
directory: resolvedDirectory,
title,
})
);
if (updated) {
this.session.set(updated);
return updated.recording ?? null;
}
return null;
}
async stopRecording(): Promise<EmbeddedMpvSession['recording'] | null> {
const id = this.sessionId();
const electron = this.getElectronBridge();
if (!id || !electron?.stopEmbeddedMpvRecording) {
return null;
}
const stopEmbeddedMpvRecording = electron.stopEmbeddedMpvRecording;
const updated = await this.guardIpc(() => stopEmbeddedMpvRecording(id));
if (updated) {
this.session.set(updated);
return updated.recording ?? null;
}
return null;
}
private async guardIpc<T>(call: () => Promise<T>): Promise<T | null> {
try {
return await call();
} catch {
// The session may have been torn down or an addon-side throw
// raced the IPC. Swallow — the next snapshot will resync state.
return null;
}
}
): Promise<EmbeddedMpvSession['recording'] | null> =>
this.commands.startRecording(directory, title);
readonly stopRecording = (): Promise<
EmbeddedMpvSession['recording'] | null
> => this.commands.stopRecording();
private async loadSupport(): Promise<void> {
try {
@@ -435,94 +307,4 @@ export class EmbeddedMpvSessionController {
private getElectronBridge(): ElectronBridge | undefined {
return window.electron;
}
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',
recording: { active: false },
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',
recording: { active: false },
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,79 @@
import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
/**
* Pure snapshot factories for the placeholder {@link EmbeddedMpvSession} states
* the controller renders before (or instead of) a real native session. Keeping
* them side-effect free means the controller owns all signal mutations — the
* error placeholder no longer clears `sessionId` itself; its caller does.
*/
function baseSession(
overrides: Partial<EmbeddedMpvSession>
): EmbeddedMpvSession {
const now = new Date().toISOString();
return {
id: 'embedded-mpv-placeholder',
title: '',
streamUrl: '',
status: 'loading',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: now,
updatedAt: now,
...overrides,
};
}
export function createLoadingSession(
playback: ResolvedPortalPlayback,
volume: number
): EmbeddedMpvSession {
return baseSession({
id: 'embedded-mpv-starting',
title: playback.title,
streamUrl: playback.streamUrl,
status: 'loading',
volume,
});
}
export function createErrorSession(
playback: ResolvedPortalPlayback,
volume: number,
error: unknown
): EmbeddedMpvSession {
return baseSession({
id: 'embedded-mpv-error',
title: playback.title,
streamUrl: playback.streamUrl,
status: 'error',
volume,
error: error instanceof Error ? error.message : String(error),
});
}
/**
* Wait two animation frames so the loading placeholder paints before the
* (potentially blocking) native session creation kicks off.
*/
export function waitForStartupPaint(): Promise<void> {
if (typeof requestAnimationFrame !== 'function') {
return Promise.resolve();
}
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
@@ -0,0 +1,71 @@
import { EmbeddedMpvStalledTracker } from './embedded-mpv-stalled-tracker';
describe('EmbeddedMpvStalledTracker', () => {
let tracker: EmbeddedMpvStalledTracker;
beforeEach(() => {
jest.useFakeTimers();
tracker = new EmbeddedMpvStalledTracker();
});
afterEach(() => {
tracker.cancel();
jest.useRealTimers();
});
it('flags a stall when loading exceeds the timeout', () => {
tracker.track('loading');
expect(tracker.stalled()).toBe(false);
jest.advanceTimersByTime(29_999);
expect(tracker.stalled()).toBe(false);
jest.advanceTimersByTime(1);
expect(tracker.stalled()).toBe(true);
});
it('keeps a single timer across repeated loading snapshots', () => {
tracker.track('loading');
jest.advanceTimersByTime(20_000);
// Position-poll snapshots re-report 'loading' — must not restart timer.
tracker.track('loading');
jest.advanceTimersByTime(10_000);
expect(tracker.stalled()).toBe(true);
});
it('cancels the pending timer when playback starts', () => {
tracker.track('loading');
tracker.track('playing');
jest.advanceTimersByTime(60_000);
expect(tracker.stalled()).toBe(false);
});
it('clears an already-raised stall on any non-loading status', () => {
tracker.track('loading');
jest.advanceTimersByTime(30_000);
expect(tracker.stalled()).toBe(true);
tracker.track('error');
expect(tracker.stalled()).toBe(false);
});
it('reset cancels the timer and clears the flag', () => {
tracker.track('loading');
jest.advanceTimersByTime(30_000);
expect(tracker.stalled()).toBe(true);
tracker.reset();
expect(tracker.stalled()).toBe(false);
jest.advanceTimersByTime(60_000);
expect(tracker.stalled()).toBe(false);
});
it('cancel alone leaves an existing stalled flag untouched', () => {
tracker.track('loading');
jest.advanceTimersByTime(30_000);
tracker.cancel();
expect(tracker.stalled()).toBe(true);
});
});
@@ -0,0 +1,45 @@
import { signal } from '@angular/core';
import { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
const STALLED_TIMEOUT_MS = 30_000;
/**
* Tracks whether a loading embedded-MPV session has stalled: if `status` stays
* `loading` longer than {@link STALLED_TIMEOUT_MS}, `stalled` flips true so the
* UI can offer a retry. Any non-loading status cancels the timer and clears the
* flag. Owns its own timer so the controller stays lifecycle-focused.
*/
export class EmbeddedMpvStalledTracker {
readonly stalled = signal(false);
private stalledTimer: number | null = null;
track(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.cancel();
if (this.stalled()) {
this.stalled.set(false);
}
}
reset(): void {
this.cancel();
this.stalled.set(false);
}
cancel(): void {
if (this.stalledTimer !== null) {
clearTimeout(this.stalledTimer);
this.stalledTimer = null;
}
}
}
-1
View File
@@ -120,7 +120,6 @@ export const maxLinesBaseline = [
'libs/ui/components/src/lib/season-container/season-container.component.ts',
'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts',
'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts',
'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts',
'libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts',
'libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts',
'libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts',