diff --git a/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts b/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts index 2f13d030d..3c0167e9a 100644 --- a/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts +++ b/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts @@ -83,6 +83,12 @@ interface PumpState { let pump: PumpState | null = null; let sourceListenerRegistered = false; +/** + * Monotonic attach epoch: bumped by every attach/detach. Async attach waits + * re-check it after each await so a stale attach (for a session that was + * replaced meanwhile) can never install itself over the active pump. + */ +let attachEpoch = 0; function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -226,14 +232,17 @@ export async function attachEmbeddedMpvFrameView( sessionId: string ): Promise { detachEmbeddedMpvFrameView(); + const epoch = ++attachEpoch; ensureSourceListener(); const canvas = await waitForCanvas(); + if (epoch !== attachEpoch) return false; if (!canvas) { console.error('[embedded-mpv-pump] frame canvas not found in DOM'); return false; } const source = await waitForFrameSource(sessionId); + if (epoch !== attachEpoch) return false; if (!source) { console.error('[embedded-mpv-pump] no frame source for', sessionId); return false; @@ -276,6 +285,7 @@ export async function attachEmbeddedMpvFrameView( } export function detachEmbeddedMpvFrameView(): void { + attachEpoch++; // aborts any attach still waiting on canvas/frame source if (!pump) return; cancelAnimationFrame(pump.rafHandle); try { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 1f9a22e14..9e5fcf582 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -206,6 +206,28 @@ describe('EmbeddedMpvNativeService power blocker', () => { }; } + it('falls back to the native engine when frame-copy is requested without a helper binary', () => { + // A stale opt-in (cleaned native build) must not brick embedded MPV: + // no helper on disk => the engine env flag is ignored, native keeps + // working, and support does not advertise frame-copy. + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + jest.spyOn( + service as unknown as { + resolveFrameCopyHelperPath: () => string | null; + }, + 'resolveFrameCopyHelperPath' + ).mockReturnValue(null); + try { + expect(service.getActiveEngine()).toBe('native'); + const support = service.getSupport(); + expect(support.engine).not.toBe('frame-copy'); + startSession('s-fallback', snapshot('loading')); + expect(addon.createSession).toHaveBeenCalled(); + } finally { + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; + } + }); + it('does not acquire a blocker for a loading session', () => { startSession('s1', snapshot('loading')); expect(powerSaveBlockerMock.start).not.toHaveBeenCalled(); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 39f17b671..9b57f1352 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -122,10 +122,14 @@ export class EmbeddedMpvNativeService { } private isFrameCopyEngineActive(): boolean { + // Requires the helper binary too: a stale opt-in (cleaned native + // build, bad install) must fall back to the native engine instead + // of leaving embedded MPV unsupported with no way to recover. return ( this.isFrameCopyEngineRequested() && process.platform === 'darwin' && - process.arch === 'arm64' + process.arch === 'arm64' && + this.resolveFrameCopyHelperPath() !== null ); } @@ -241,33 +245,16 @@ export class EmbeddedMpvNativeService { }; } - if (this.isFrameCopyEngineRequested()) { - if (!this.isFrameCopyEngineActive()) { - return { - supported: false, - platform: process.platform, - reason: 'The frame-copy embedded MPV engine is currently supported on Apple Silicon macOS only.', - }; - } - if (!this.resolveFrameCopyHelperPath()) { - return { - supported: false, - platform: process.platform, - reason: 'The frame-copy embedded MPV helper binary was not found. Rebuild the native target (pnpm run serve:backend:embedded-mpv rebuilds it).', - }; - } + // A requested-but-unavailable frame-copy engine (wrong platform or + // missing helper) intentionally falls through to the native path so + // embedded MPV keeps working and Settings can clear the opt-in. + if (this.isFrameCopyEngineActive()) { return { supported: true, platform: process.platform, engine: 'frame-copy', frameCopyAvailable: true, - capabilities: { - subtitles: true, - playbackSpeed: true, - aspectOverride: true, - screenshot: false, - recording: true, - }, + capabilities: this.detectCapabilities(), }; } diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index c368b3c7d..b698fe1cd 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -114,7 +114,7 @@ - @if (frameCopyAvailable()) { + @if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {
this.isFrameCopyEngine())) { // Frame-copy engine: start the preload frame pump that - // paints helper frames onto the component's canvas. Failure - // is non-fatal here — the session error/stall paths cover it. - void electron + // paints helper frames onto the component's canvas. A failed + // attach (no canvas, no WebGL2, reader missing) must surface + // as a session error — otherwise the helper keeps playing + // audio behind a black canvas with no recovery UI. + const attached = await electron .attachEmbeddedMpvFrameView?.(created.id) - .catch(() => undefined); + .catch(() => false); + if (attached === false && !disposed) { + await electron + .disposeEmbeddedMpvSession(created.id) + .catch(() => undefined); + throw new Error( + 'The embedded MPV frame view failed to initialize.' + ); + } } scheduleBoundsSync(); };