diff --git a/CLAUDE.md b/CLAUDE.md index e4ce53b82..02daca064 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -554,6 +554,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Built-in HTML5 player with HLS.js or Video.js - External players: MPV, VLC (via IPC to Electron backend) +- Embedded MPV (experimental, macOS only): renders libmpv into a custom Cocoa view inside the Electron window. Because the standard `--wid` path is bypassed, mpv's own screensaver inhibition does not apply, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`. **Radio Player**: 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 new file mode 100644 index 000000000..fa731981b --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -0,0 +1,215 @@ +import type { + EmbeddedMpvBounds, + EmbeddedMpvSessionStatus, + ResolvedPortalPlayback, +} from 'shared-interfaces'; +import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service'; + +const powerSaveBlockerMock = { + start: jest.fn(), + stop: jest.fn(), + isStarted: jest.fn(), +}; + +jest.mock('electron', () => ({ + app: { + isPackaged: true, + getAppPath: () => '/mock/app.asar', + }, + powerSaveBlocker: powerSaveBlockerMock, +})); + +const mainWindowSendMock = jest.fn(); +const mainWindowMock = { + isDestroyed: () => false, + getNativeWindowHandle: () => Buffer.alloc(8), + webContents: { send: mainWindowSendMock }, +}; + +jest.mock('../app', () => ({ + __esModule: true, + default: { + get mainWindow() { + return mainWindowMock; + }, + }, +})); + +interface MockSnapshot { + status: EmbeddedMpvSessionStatus; + positionSeconds: number; + durationSeconds: number | null; + volume: number; + streamUrl: string; + audioTracks?: never[]; + selectedAudioTrackId?: number | null; + error?: string; +} + +interface MockAddon { + isSupported: jest.Mock; + createSession: jest.Mock< + string, + [Buffer, EmbeddedMpvBounds, string?, number?] + >; + loadPlayback: jest.Mock; + setBounds: jest.Mock; + setPaused: jest.Mock; + seek: jest.Mock; + setVolume: jest.Mock; + setAudioTrack: jest.Mock; + getSessionSnapshot: jest.Mock; + disposeSession: jest.Mock; +} + +function createMockAddon(): MockAddon { + return { + isSupported: jest.fn().mockReturnValue(true), + createSession: jest.fn(), + loadPlayback: jest.fn(), + setBounds: jest.fn(), + setPaused: jest.fn(), + seek: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + getSessionSnapshot: jest.fn(), + disposeSession: jest.fn(), + }; +} + +const BOUNDS: EmbeddedMpvBounds = { x: 0, y: 0, width: 100, height: 100 }; + +describe('EmbeddedMpvNativeService power blocker', () => { + let EmbeddedMpvNativeService: typeof EmbeddedMpvNativeServiceType; + let service: EmbeddedMpvNativeServiceType; + let addon: MockAddon; + let nextBlockerId: number; + + beforeEach(async () => { + jest.resetModules(); + powerSaveBlockerMock.start.mockReset(); + powerSaveBlockerMock.stop.mockReset(); + powerSaveBlockerMock.isStarted.mockReset(); + mainWindowSendMock.mockReset(); + + nextBlockerId = 1; + powerSaveBlockerMock.start.mockImplementation(() => nextBlockerId++); + powerSaveBlockerMock.isStarted.mockReturnValue(true); + + ({ EmbeddedMpvNativeService } = await import( + './embedded-mpv-native.service' + )); + service = new EmbeddedMpvNativeService(); + addon = createMockAddon(); + // The addon is normally loaded via createRequire from a vendored .node + // file. For unit tests we inject a mock implementation directly. + (service as unknown as { addon: MockAddon }).addon = addon; + }); + + afterEach(() => { + service.shutdown(); + }); + + function startSession( + sessionId: string, + snapshot: MockSnapshot + ): void { + addon.createSession.mockReturnValueOnce(sessionId); + addon.getSessionSnapshot.mockReturnValueOnce(snapshot); + service.createSession(BOUNDS, '', 1); + } + + function snapshot( + status: EmbeddedMpvSessionStatus, + overrides: Partial = {} + ): MockSnapshot { + return { + status, + positionSeconds: 0, + durationSeconds: null, + volume: 1, + streamUrl: 'mock://stream', + ...overrides, + }; + } + + it('does not acquire a blocker for a loading session', () => { + startSession('s1', snapshot('loading')); + expect(powerSaveBlockerMock.start).not.toHaveBeenCalled(); + }); + + it('acquires a single prevent-display-sleep blocker once a session is playing', () => { + startSession('s1', snapshot('loading')); + + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('playing')); + service.setPaused('s1', false); + + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + expect(powerSaveBlockerMock.start).toHaveBeenCalledWith( + 'prevent-display-sleep' + ); + + // Subsequent refreshes while still playing must not start a second blocker. + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('playing')); + service.setPaused('s1', false); + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + }); + + it('releases the blocker when the session transitions to paused', () => { + startSession('s1', snapshot('playing')); + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused')); + service.setPaused('s1', true); + + expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1); + expect(powerSaveBlockerMock.stop).toHaveBeenCalledWith(1); + }); + + it('keeps the blocker held while any other session is still playing', () => { + startSession('s1', snapshot('playing')); + startSession('s2', snapshot('playing')); + + // Only one blocker for both sessions. + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + + // s1 pauses — s2 still playing, so do not release. + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused')); + service.setPaused('s1', true); + expect(powerSaveBlockerMock.stop).not.toHaveBeenCalled(); + + // s2 pauses — now there is nothing playing. + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused')); + service.setPaused('s2', true); + expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1); + }); + + it('releases the blocker when the playing session is disposed', () => { + startSession('s1', snapshot('playing')); + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + + service.disposeSession('s1'); + + expect(addon.disposeSession).toHaveBeenCalledWith('s1'); + expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1); + }); + + it('releases the blocker on shutdown', () => { + startSession('s1', snapshot('playing')); + expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1); + + service.shutdown(); + + expect(powerSaveBlockerMock.stop).toHaveBeenCalled(); + }); + + it('skips powerSaveBlocker.stop if the assertion was already cleared externally', () => { + startSession('s1', snapshot('playing')); + powerSaveBlockerMock.isStarted.mockReturnValue(false); + + addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused')); + service.setPaused('s1', true); + + expect(powerSaveBlockerMock.stop).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 f3e5ccb20..aaf82ebd9 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 @@ -1,4 +1,4 @@ -import { app } from 'electron'; +import { app, powerSaveBlocker } from 'electron'; import { existsSync, readFileSync } from 'fs'; import { createRequire } from 'module'; import path from 'path'; @@ -51,6 +51,7 @@ interface EmbeddedMpvRuntimeSession { startedAt: string; updatedAt: string; lastPayloadKey: string; + lastStatus: EmbeddedMpvSessionStatus | null; } const EMBEDDED_MPV_EXPERIMENT_ENV = @@ -65,6 +66,7 @@ export class EmbeddedMpvNativeService { private addonLoadError: Error | null = null; private readonly sessions = new Map(); private pollingTimer: NodeJS.Timeout | null = null; + private powerBlockerId: number | null = null; private readonly loadAddonModule = createRequire(__filename); getSupport(): EmbeddedMpvSupport { @@ -209,6 +211,7 @@ export class EmbeddedMpvNativeService { startedAt, updatedAt: startedAt, lastPayloadKey: '', + lastStatus: null, }); this.ensurePolling(); @@ -292,6 +295,7 @@ export class EmbeddedMpvNativeService { }; this.sendSessionUpdate(payload); this.stopPollingIfIdle(); + this.updatePowerBlocker(); return payload; } } @@ -303,6 +307,7 @@ export class EmbeddedMpvNativeService { clearInterval(this.pollingTimer); this.pollingTimer = null; } + this.updatePowerBlocker(); } private ensurePolling(): void { @@ -363,15 +368,53 @@ export class EmbeddedMpvNativeService { session.streamUrl = payload.streamUrl; session.updatedAt = payload.updatedAt; + session.lastStatus = payload.status; const nextPayloadKey = JSON.stringify(payload); if (session.lastPayloadKey !== nextPayloadKey) { session.lastPayloadKey = nextPayloadKey; this.sendSessionUpdate(payload); } + this.updatePowerBlocker(); return payload; } + private hasPlayingSession(): boolean { + for (const session of this.sessions.values()) { + if (session.lastStatus === 'playing') { + return true; + } + } + return false; + } + + private updatePowerBlocker(): void { + const shouldBlock = this.hasPlayingSession(); + + if (shouldBlock && this.powerBlockerId === null) { + try { + this.powerBlockerId = powerSaveBlocker.start( + 'prevent-display-sleep' + ); + } catch { + this.powerBlockerId = null; + } + return; + } + + if (!shouldBlock && this.powerBlockerId !== null) { + const blockerId = this.powerBlockerId; + this.powerBlockerId = null; + try { + if (powerSaveBlocker.isStarted(blockerId)) { + powerSaveBlocker.stop(blockerId); + } + } catch { + // ignore — assertion will be cleaned up when the process exits + } + } + } + private sendSessionUpdate(session: EmbeddedMpvSession): void { if (!App.mainWindow || App.mainWindow.isDestroyed()) { return;