mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix: prevent macOS sleep during embedded MPV playback
The embedded MPV player renders via libmpv into a custom Cocoa view, which bypasses mpv's built-in screensaver inhibition. Hold an Electron powerSaveBlocker (prevent-display-sleep) while any session is playing and release it on pause, dispose, or shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: f957cd9849e0
This commit is contained in:
1 parent
4a21579aff
commit
a74b8f1b81
3 files changed
+260
-1
No files matched your search
@@ -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**:
|
||||
|
||||
|
||||
@@ -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<number, [string]>(),
|
||||
stop: jest.fn<void, [number]>(),
|
||||
isStarted: jest.fn<boolean, [number]>(),
|
||||
};
|
||||
|
||||
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<boolean, []>;
|
||||
createSession: jest.Mock<
|
||||
string,
|
||||
[Buffer, EmbeddedMpvBounds, string?, number?]
|
||||
>;
|
||||
loadPlayback: jest.Mock<void, [string, ResolvedPortalPlayback]>;
|
||||
setBounds: jest.Mock<void, [string, EmbeddedMpvBounds]>;
|
||||
setPaused: jest.Mock<void, [string, boolean]>;
|
||||
seek: jest.Mock<void, [string, number]>;
|
||||
setVolume: jest.Mock<void, [string, number]>;
|
||||
setAudioTrack: jest.Mock<void, [string, number]>;
|
||||
getSessionSnapshot: jest.Mock<MockSnapshot | null, [string]>;
|
||||
disposeSession: jest.Mock<void, [string]>;
|
||||
}
|
||||
|
||||
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> = {}
|
||||
): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, EmbeddedMpvRuntimeSession>();
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user