diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index c38499392..e7c57fad4 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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`. 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 diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts new file mode 100644 index 000000000..747623338 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts @@ -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 { + 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('mpv-1'); + const session = signal(createSession()); + let runner: EmbeddedMpvCommandRunner; + let electron: Record; + + 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((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((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); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts new file mode 100644 index 000000000..5a4a66801 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts @@ -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; + readonly session: WritableSignal; +} + +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + ): Promise { + 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(call: () => Promise): Promise { + try { + return await call(); + } catch { + return null; + } + } + + private bridge(): ElectronBridge | undefined { + return window.electron; + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts index f91ae12b3..6678c291b 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-format.utils.ts @@ -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, diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts new file mode 100644 index 000000000..f708cdb7d --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts @@ -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((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((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 { + 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 { + 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}`); +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts index 557fea9e3..d2a60a690 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts @@ -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'); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index accf70251..36e6e11cb 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -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(null); readonly session = signal(null); readonly sessionId = signal(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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 => this.commands.togglePaused(); + readonly seekBy = (deltaSeconds: number): Promise => + this.commands.seekBy(deltaSeconds); + readonly seekTo = (seconds: number): Promise => + this.commands.seekTo(seconds); + readonly applyVolume = (value: number): Promise => + this.commands.applyVolume(value); + readonly setAudioTrack = (trackId: number): Promise => + this.commands.setAudioTrack(trackId); + readonly setSubtitleTrack = (trackId: number): Promise => + this.commands.setSubtitleTrack(trackId); + readonly setSpeed = (speed: number): Promise => + this.commands.setSpeed(speed); + readonly setAspect = (aspect: string): Promise => + this.commands.setAspect(aspect); + readonly startRecording = ( directory: string | undefined, title: string - ): Promise { - 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 { - 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(call: () => Promise): Promise { - 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 => + this.commands.startRecording(directory, title); + readonly stopRecording = (): Promise< + EmbeddedMpvSession['recording'] | null + > => this.commands.stopRecording(); private async loadSupport(): Promise { 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 { - if (typeof requestAnimationFrame !== 'function') { - return Promise.resolve(); - } - return new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); - } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-factory.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-factory.ts new file mode 100644 index 000000000..e28be1678 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-factory.ts @@ -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 { + 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 { + if (typeof requestAnimationFrame !== 'function') { + return Promise.resolve(); + } + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-stalled-tracker.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-stalled-tracker.spec.ts new file mode 100644 index 000000000..1f2e3c1e8 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-stalled-tracker.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-stalled-tracker.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-stalled-tracker.ts new file mode 100644 index 000000000..2f9915131 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-stalled-tracker.ts @@ -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; + } + } +} diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 4ccadc2cf..c104483bf 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -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',