fix(playback): keep the display awake while built-in players play video

Closes #1095. The renderer tracks every playing <video> through
document-level capture listeners (element-level release listeners catch
the detached-element pause on component teardown) and, while any video
is playing and the document is visible, holds a display-sleep lock:
a main-process powerSaveBlocker over IPC in Electron — reliable on
Linux where Chromium's own video wake lock depends on DE D-Bus
inhibitors — and the Screen Wake Lock API in the PWA. The vote is
auto-cleared when the renderer reloads or dies. Radio's <audio>
deliberately never blocks display sleep; embedded MPV and external
MPV/VLC already manage their own inhibition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-10 21:40:05 +02:00
1 parent d73551d780
commit 7349ed6511
11 files changed
+721

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
---
type: fix
area: playback
issues: [1095]
---
The screen no longer dims or goes to sleep while a built-in player is playing
video — including on Linux desktops, where the system idle timer used to
ignore the app. Pausing or stopping hands control back to the system
immediately, and radio playback deliberately leaves the display free to
sleep. Works in both the desktop app and the PWA.
+13
View File
@@ -933,6 +933,19 @@ app as a real argument, so it is not an option.
KODIPROP DRM still suppress it. Details in
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
- External players: MPV, VLC (via IPC to Electron backend)
- Display sleep during playback: `PlaybackKeepAwakeService`
(`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every
`<video>` via document-level capture listeners (media events don't bubble;
release listeners sit on the tracked element because Chromium's
removed-from-DOM pause never reaches the document) and, while any video is
playing and the document is visible, holds a display-sleep lock: in
Electron a main-process `powerSaveBlocker` behind
`window.electron.setPlaybackKeepAwake`
(`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`;
auto-cleared on renderer reload/crash), in the PWA the Screen Wake Lock
API. Radio's `<audio>` deliberately never blocks display sleep. Embedded
MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC
inhibit the screensaver themselves.
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
@@ -84,6 +84,7 @@ const WINDOW_SET_CLOSE_GUARD = 'WINDOW:SET_CLOSE_GUARD';
const WINDOW_CONFIRM_CLOSE = 'WINDOW:CONFIRM_CLOSE';
const WINDOW_CANCEL_CLOSE = 'WINDOW:CANCEL_CLOSE';
const WINDOW_CLOSE_REQUESTED = 'WINDOW:CLOSE_REQUESTED';
const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE';
const dbSaveContentProgressListeners = new Set<
(
@@ -435,6 +436,8 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.on(WINDOW_CLOSE_REQUESTED, handler);
return () => ipcRenderer.off(WINDOW_CLOSE_REQUESTED, handler);
},
setPlaybackKeepAwake: (active: boolean) =>
ipcRenderer.invoke(PLAYBACK_SET_KEEP_AWAKE, active === true),
fetchPlaylistByUrl: (
url: string,
title?: string,
@@ -1,8 +1,10 @@
import { ipcMain } from 'electron';
import {
CLOSE_EXTERNAL_PLAYER_SESSION,
PLAYBACK_SET_KEEP_AWAKE,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { setPlaybackKeepAwake } from '../services/playback-keep-awake.service';
import {
MPV_PLAYER_PATH,
store,
@@ -128,3 +130,7 @@ ipcMain.handle(
return externalPlayerSessions.closeSession(sessionId);
}
);
ipcMain.handle(PLAYBACK_SET_KEEP_AWAKE, (event, active: boolean) => {
setPlaybackKeepAwake(event.sender, active === true);
});
@@ -0,0 +1,151 @@
jest.mock('electron', () => ({
powerSaveBlocker: {
start: jest.fn(),
stop: jest.fn(),
isStarted: jest.fn(),
},
}));
import { powerSaveBlocker, WebContents } from 'electron';
import {
resetPlaybackKeepAwakeForTesting,
setPlaybackKeepAwake,
} from './playback-keep-awake.service';
type NavigationListener = (event: {
isMainFrame: boolean;
isSameDocument: boolean;
}) => void;
const mockedBlocker = powerSaveBlocker as jest.Mocked<typeof powerSaveBlocker>;
function createSenderStub(id: number) {
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
const sender = {
id,
on: jest.fn((event: string, listener: (...args: unknown[]) => void) => {
const existing = listeners.get(event) ?? new Set();
existing.add(listener);
listeners.set(event, existing);
return sender;
}),
off: jest.fn(
(event: string, listener: (...args: unknown[]) => void) => {
listeners.get(event)?.delete(listener);
return sender;
}
),
};
return {
sender: sender as unknown as WebContents,
emit: (event: string, ...args: unknown[]) => {
for (const listener of listeners.get(event) ?? []) {
listener(...args);
}
},
listenerCount: (event: string) => listeners.get(event)?.size ?? 0,
};
}
describe('playback keep-awake service', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedBlocker.start.mockReturnValue(7);
mockedBlocker.isStarted.mockReturnValue(true);
resetPlaybackKeepAwakeForTesting();
jest.clearAllMocks();
mockedBlocker.start.mockReturnValue(7);
mockedBlocker.isStarted.mockReturnValue(true);
});
it('starts the display blocker on activation and stops it on release', () => {
const { sender } = createSenderStub(1);
setPlaybackKeepAwake(sender, true);
expect(mockedBlocker.start).toHaveBeenCalledTimes(1);
expect(mockedBlocker.start).toHaveBeenCalledWith(
'prevent-display-sleep'
);
setPlaybackKeepAwake(sender, false);
expect(mockedBlocker.stop).toHaveBeenCalledWith(7);
});
it('holds a single blocker across repeated activations', () => {
const { sender } = createSenderStub(1);
setPlaybackKeepAwake(sender, true);
setPlaybackKeepAwake(sender, true);
expect(mockedBlocker.start).toHaveBeenCalledTimes(1);
});
it('does not stop a blocker that was never started', () => {
const { sender } = createSenderStub(1);
setPlaybackKeepAwake(sender, false);
expect(mockedBlocker.start).not.toHaveBeenCalled();
expect(mockedBlocker.stop).not.toHaveBeenCalled();
});
it('releases the blocker when the voting webContents is destroyed', () => {
const stub = createSenderStub(1);
setPlaybackKeepAwake(stub.sender, true);
stub.emit('destroyed');
expect(mockedBlocker.stop).toHaveBeenCalledWith(7);
});
it('releases the blocker on a main-frame navigation (reload)', () => {
const stub = createSenderStub(1);
setPlaybackKeepAwake(stub.sender, true);
stub.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: false,
} satisfies Parameters<NavigationListener>[0]);
expect(mockedBlocker.stop).toHaveBeenCalledWith(7);
});
it('keeps the blocker across same-document navigations (Angular routing)', () => {
const stub = createSenderStub(1);
setPlaybackKeepAwake(stub.sender, true);
stub.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: true,
} satisfies Parameters<NavigationListener>[0]);
expect(mockedBlocker.stop).not.toHaveBeenCalled();
});
it('detaches its lifetime listeners once the vote is withdrawn', () => {
const stub = createSenderStub(1);
setPlaybackKeepAwake(stub.sender, true);
expect(stub.listenerCount('destroyed')).toBe(1);
expect(stub.listenerCount('did-start-navigation')).toBe(1);
setPlaybackKeepAwake(stub.sender, false);
expect(stub.listenerCount('destroyed')).toBe(0);
expect(stub.listenerCount('did-start-navigation')).toBe(0);
});
it('survives a powerSaveBlocker.start failure and can retry later', () => {
const { sender } = createSenderStub(1);
mockedBlocker.start.mockImplementationOnce(() => {
throw new Error('no dbus');
});
setPlaybackKeepAwake(sender, true);
expect(mockedBlocker.stop).not.toHaveBeenCalled();
// The failed attempt left no blocker; a fresh cycle starts one.
setPlaybackKeepAwake(sender, false);
setPlaybackKeepAwake(sender, true);
expect(mockedBlocker.start).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,104 @@
import { powerSaveBlocker, WebContents } from 'electron';
/**
* Keeps the display awake while a built-in web player (HTML5/hls.js,
* Video.js, ArtPlayer) is playing video in the renderer (issue #1095).
*
* Chromium is supposed to hold a video wake lock on its own, but on Linux
* that goes through DE-dependent D-Bus inhibitors and is not reliable, so the
* renderer reports playback activity explicitly over IPC and the main process
* holds a single `powerSaveBlocker('prevent-display-sleep')` — the same
* mechanism `EmbeddedMpvNativeService` already uses for embedded MPV
* sessions. External MPV/VLC inhibit the screensaver themselves.
*
* The renderer's flag must not outlive the page that set it: a reload or a
* crashed render process would otherwise pin the display awake until app
* quit. Each activating WebContents therefore gets destroy/navigation
* listeners that withdraw its vote.
*/
const activeSenders = new Set<number>();
const senderCleanups = new Map<number, () => void>();
let blockerId: number | null = null;
export function setPlaybackKeepAwake(
sender: WebContents,
active: boolean
): void {
if (active) {
if (!activeSenders.has(sender.id)) {
activeSenders.add(sender.id);
watchSenderLifetime(sender);
}
} else {
clearSender(sender.id);
}
syncBlocker();
}
/** Test-only: drop all votes and release the blocker. */
export function resetPlaybackKeepAwakeForTesting(): void {
for (const cleanup of senderCleanups.values()) {
cleanup();
}
senderCleanups.clear();
activeSenders.clear();
syncBlocker();
}
function watchSenderLifetime(sender: WebContents): void {
const senderId = sender.id;
const clear = () => {
clearSender(senderId);
syncBlocker();
};
const onNavigation = (
event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>
) => {
// A main-frame load replaces the page that voted; same-document
// navigations (Angular routing) keep the player alive.
if (event.isMainFrame && !event.isSameDocument) {
clear();
}
};
sender.on('destroyed', clear);
sender.on('did-start-navigation', onNavigation);
senderCleanups.set(senderId, () => {
sender.off('destroyed', clear);
sender.off('did-start-navigation', onNavigation);
});
}
function clearSender(senderId: number): void {
if (!activeSenders.delete(senderId)) {
return;
}
const cleanup = senderCleanups.get(senderId);
senderCleanups.delete(senderId);
cleanup?.();
}
function syncBlocker(): void {
const shouldBlock = activeSenders.size > 0;
if (shouldBlock && blockerId === null) {
try {
blockerId = powerSaveBlocker.start('prevent-display-sleep');
} catch {
blockerId = null;
}
return;
}
if (!shouldBlock && blockerId !== null) {
const idToStop = blockerId;
blockerId = null;
try {
if (powerSaveBlocker.isStarted(idToStop)) {
powerSaveBlocker.stop(idToStop);
}
} catch {
// ignore — the assertion dies with the process anyway
}
}
}
+6
View File
@@ -30,6 +30,7 @@ import {
createDevLogger,
} from '@iptvnator/shared/interfaces';
import { SettingsService } from './services/settings.service';
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
@@ -62,6 +63,7 @@ export class AppComponent implements OnInit {
private translate = inject(TranslateService);
private settingsService = inject(SettingsService);
private settingsStore = inject(SettingsStore);
private playbackKeepAwake = inject(PlaybackKeepAwakeService);
private playlistOpenRequests = inject(PlaylistOpenRequestService);
private runtime = inject(RuntimeCapabilitiesService);
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
@@ -83,6 +85,10 @@ export class AppComponent implements OnInit {
// as possible so a first-launch file is not delayed behind app init.
this.playlistOpenRequests.start();
// Keep the display awake while a built-in player is playing video
// (Electron powerSaveBlocker / PWA Screen Wake Lock, issue #1095).
this.playbackKeepAwake.start();
effect(() => {
const size = this.settingsStore.coverSize?.() ?? 'medium';
document.documentElement.dataset.coverSize = size;
@@ -0,0 +1,226 @@
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
type ElectronWindow = Window & {
electron?: { setPlaybackKeepAwake?: jest.Mock };
};
const flush = () => Promise.resolve().then(() => Promise.resolve());
describe('PlaybackKeepAwakeService', () => {
let service: PlaybackKeepAwakeService;
let video: HTMLVideoElement;
let visibilityState: DocumentVisibilityState;
const setVisibility = (state: DocumentVisibilityState) => {
visibilityState = state;
document.dispatchEvent(new Event('visibilitychange'));
};
beforeEach(() => {
visibilityState = 'visible';
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibilityState,
});
service = new PlaybackKeepAwakeService();
video = document.createElement('video');
document.body.appendChild(video);
});
afterEach(() => {
service.stop();
video.remove();
delete (window as ElectronWindow).electron;
delete (document as { visibilityState?: unknown }).visibilityState;
});
describe('with the Electron bridge', () => {
let setPlaybackKeepAwake: jest.Mock;
beforeEach(() => {
setPlaybackKeepAwake = jest.fn().mockResolvedValue(undefined);
(window as ElectronWindow).electron = { setPlaybackKeepAwake };
service.start();
});
it('activates on playing and releases on pause', () => {
video.dispatchEvent(new Event('playing'));
expect(setPlaybackKeepAwake).toHaveBeenCalledTimes(1);
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(true);
video.dispatchEvent(new Event('pause'));
expect(setPlaybackKeepAwake).toHaveBeenCalledTimes(2);
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(false);
});
it.each(['ended', 'emptied', 'error'] as const)(
'releases on %s',
(eventType) => {
video.dispatchEvent(new Event('playing'));
video.dispatchEvent(new Event(eventType));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(false);
}
);
it('holds the lock until the last playing video stops', () => {
const second = document.createElement('video');
document.body.appendChild(second);
video.dispatchEvent(new Event('playing'));
second.dispatchEvent(new Event('playing'));
expect(setPlaybackKeepAwake).toHaveBeenCalledTimes(1);
video.dispatchEvent(new Event('pause'));
expect(setPlaybackKeepAwake).toHaveBeenCalledTimes(1);
second.dispatchEvent(new Event('pause'));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(false);
second.remove();
});
it('does not react to repeated playing events of a tracked video', () => {
video.dispatchEvent(new Event('playing'));
video.dispatchEvent(new Event('playing'));
expect(setPlaybackKeepAwake).toHaveBeenCalledTimes(1);
});
it('ignores audio elements so radio keeps the display free to sleep', () => {
const audio = document.createElement('audio');
document.body.appendChild(audio);
audio.dispatchEvent(new Event('playing'));
expect(setPlaybackKeepAwake).not.toHaveBeenCalled();
audio.remove();
});
it('releases when a tracked video pauses after DOM removal', () => {
video.dispatchEvent(new Event('playing'));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(true);
// Chromium pauses removed media elements, but that pause fires on
// the detached element and never reaches the document listener.
video.remove();
video.dispatchEvent(new Event('pause'));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(false);
});
it('drops the lock while the document is hidden and re-acquires on return', () => {
video.dispatchEvent(new Event('playing'));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(true);
setVisibility('hidden');
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(false);
setVisibility('visible');
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(true);
expect(setPlaybackKeepAwake).toHaveBeenCalledTimes(3);
});
it('retries after a failed IPC call on the next state change', async () => {
setPlaybackKeepAwake.mockRejectedValueOnce(new Error('ipc down'));
video.dispatchEvent(new Event('playing'));
await flush();
video.dispatchEvent(new Event('pause'));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(false);
});
it('stop() releases the lock and detaches all listeners', () => {
video.dispatchEvent(new Event('playing'));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(true);
service.stop();
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(false);
video.dispatchEvent(new Event('playing'));
expect(setPlaybackKeepAwake).toHaveBeenCalledTimes(2);
});
});
describe('with the Screen Wake Lock API (PWA)', () => {
let request: jest.Mock;
let release: jest.Mock;
let sentinel: {
release: jest.Mock;
addEventListener: jest.Mock;
};
beforeEach(() => {
release = jest.fn().mockResolvedValue(undefined);
sentinel = { release, addEventListener: jest.fn() };
request = jest.fn().mockResolvedValue(sentinel);
Object.defineProperty(navigator, 'wakeLock', {
configurable: true,
value: { request },
});
service.start();
});
afterEach(() => {
delete (navigator as { wakeLock?: unknown }).wakeLock;
});
it('requests a screen wake lock on playing and releases it on pause', async () => {
video.dispatchEvent(new Event('playing'));
expect(request).toHaveBeenCalledWith('screen');
await flush();
video.dispatchEvent(new Event('pause'));
expect(release).toHaveBeenCalled();
});
it('releases a lock that resolves after playback already stopped', async () => {
let resolveRequest:
| ((value: typeof sentinel) => void)
| undefined;
request.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRequest = resolve;
})
);
video.dispatchEvent(new Event('playing'));
video.dispatchEvent(new Event('pause'));
resolveRequest?.(sentinel);
await flush();
expect(release).toHaveBeenCalled();
});
it('re-requests the lock after the browser auto-released it', async () => {
video.dispatchEvent(new Event('playing'));
await flush();
// The browser releases the sentinel itself when the page hides.
const onRelease = sentinel.addEventListener.mock.calls.find(
([type]) => type === 'release'
)?.[1] as () => void;
setVisibility('hidden');
onRelease();
setVisibility('visible');
expect(request).toHaveBeenCalledTimes(2);
});
it('survives a denied wake lock request', async () => {
request.mockRejectedValueOnce(new Error('denied'));
video.dispatchEvent(new Event('playing'));
await flush();
// A later event retries.
video.dispatchEvent(new Event('pause'));
video.dispatchEvent(new Event('playing'));
expect(request).toHaveBeenCalledTimes(2);
});
it('does nothing when the API is unavailable', () => {
delete (navigator as { wakeLock?: unknown }).wakeLock;
expect(() =>
video.dispatchEvent(new Event('playing'))
).not.toThrow();
});
});
});
@@ -0,0 +1,189 @@
import { Injectable } from '@angular/core';
type PlaybackKeepAwakeBridge = {
setPlaybackKeepAwake?: (active: boolean) => Promise<void>;
};
type WakeLockSentinelLike = {
release(): Promise<void>;
addEventListener?(type: 'release', listener: () => void): void;
};
type WakeLockNavigator = Navigator & {
wakeLock?: { request(type: 'screen'): Promise<WakeLockSentinelLike> };
};
/** Events on a tracked video that mean it is no longer playing. */
const RELEASE_EVENTS = ['pause', 'ended', 'emptied', 'error'] as const;
/**
* Keeps the display awake while any built-in web player (HTML5/hls.js,
* Video.js, ArtPlayer) is playing video (issue #1095).
*
* Detection is a document-level capture listener for `playing`: media events
* don't bubble, but capture still sees them from every `<video>` in the page,
* so all current and future player surfaces are covered without per-engine
* wiring. Only `HTMLVideoElement` counts — radio's `<audio>` deliberately
* leaves the display free to sleep, matching browser behavior for audio.
*
* Once a video is tracked, its release listeners sit on the element itself:
* Chromium pauses a media element removed from the DOM, but that `pause`
* fires on the detached element and never reaches the document, so a
* document-only listener would leak the lock on component teardown.
*
* In Electron the lock is a main-process `powerSaveBlocker` behind
* `window.electron.setPlaybackKeepAwake` (Chromium's own video wake lock is
* unreliable on Linux — DE-dependent D-Bus inhibitors, see the issue). In the
* PWA it is the standard Screen Wake Lock API. Embedded MPV and external
* MPV/VLC render no `<video>` here and manage display sleep themselves.
*
* Visibility gates the lock in both modes: a minimized window streaming
* audio in the background should not pin the display on. The wake lock is
* re-requested on `visibilitychange` because the browser auto-releases it
* when the page hides.
*/
@Injectable({ providedIn: 'root' })
export class PlaybackKeepAwakeService {
private started = false;
private readonly playingVideos = new Set<HTMLVideoElement>();
private readonly releaseCleanups = new Map<HTMLVideoElement, () => void>();
private lastSentToBridge: boolean | null = null;
private wakeLock: WakeLockSentinelLike | null = null;
private wakeLockRequestInFlight = false;
private readonly onPlaying = (event: Event) => {
const target = event.target;
if (target instanceof HTMLVideoElement) {
this.trackVideo(target);
}
};
private readonly onVisibilityChange = () => {
this.sync();
};
start(): void {
if (this.started) {
return;
}
this.started = true;
document.addEventListener('playing', this.onPlaying, true);
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
stop(): void {
if (!this.started) {
return;
}
this.started = false;
document.removeEventListener('playing', this.onPlaying, true);
document.removeEventListener(
'visibilitychange',
this.onVisibilityChange
);
for (const video of [...this.playingVideos]) {
this.untrackVideo(video);
}
}
private trackVideo(video: HTMLVideoElement): void {
if (this.playingVideos.has(video)) {
return;
}
this.playingVideos.add(video);
const onRelease = () => this.untrackVideo(video);
for (const type of RELEASE_EVENTS) {
video.addEventListener(type, onRelease);
}
this.releaseCleanups.set(video, () => {
for (const type of RELEASE_EVENTS) {
video.removeEventListener(type, onRelease);
}
});
this.sync();
}
private untrackVideo(video: HTMLVideoElement): void {
if (!this.playingVideos.delete(video)) {
return;
}
const cleanup = this.releaseCleanups.get(video);
this.releaseCleanups.delete(video);
cleanup?.();
this.sync();
}
private sync(): void {
const shouldBlock =
this.playingVideos.size > 0 &&
document.visibilityState === 'visible';
const bridge = this.getBridge();
if (bridge?.setPlaybackKeepAwake) {
if (this.lastSentToBridge !== shouldBlock) {
this.lastSentToBridge = shouldBlock;
bridge.setPlaybackKeepAwake(shouldBlock).catch(() => {
// Retry on the next state change.
this.lastSentToBridge = null;
});
}
return;
}
if (shouldBlock) {
this.acquireWakeLock();
} else {
this.releaseWakeLock();
}
}
private acquireWakeLock(): void {
if (this.wakeLock || this.wakeLockRequestInFlight) {
return;
}
const wakeLock = (navigator as WakeLockNavigator).wakeLock;
if (!wakeLock) {
return;
}
this.wakeLockRequestInFlight = true;
wakeLock
.request('screen')
.then((sentinel) => {
this.wakeLockRequestInFlight = false;
// The browser releases the sentinel on its own when the page
// hides; forget it so the next sync() can re-request.
sentinel.addEventListener?.('release', () => {
if (this.wakeLock === sentinel) {
this.wakeLock = null;
}
});
const stillWanted =
this.playingVideos.size > 0 &&
document.visibilityState === 'visible';
if (!stillWanted) {
sentinel.release().catch(() => undefined);
return;
}
this.wakeLock = sentinel;
})
.catch(() => {
// Denied (battery saver, hidden document, …) — a later
// `playing` or visibility event retries via sync().
this.wakeLockRequestInFlight = false;
});
}
private releaseWakeLock(): void {
const sentinel = this.wakeLock;
if (!sentinel) {
return;
}
this.wakeLock = null;
sentinel.release().catch(() => undefined);
}
private getBridge(): PlaybackKeepAwakeBridge | undefined {
return (window as { electron?: PlaybackKeepAwakeBridge }).electron;
}
}
@@ -639,6 +639,14 @@ export interface ElectronBridgeApi {
onWindowCloseRequested: (
callback: (requestId: number) => void
) => () => void;
/**
* While active, the main process holds a
* `powerSaveBlocker('prevent-display-sleep')` so the screen stays awake
* during built-in video playback. The flag is cleared automatically when
* the renderer navigates, reloads, or dies, so a crashed page can never
* leave the display pinned awake.
*/
setPlaybackKeepAwake: (active: boolean) => Promise<void>;
fetchPlaylistByUrl: (
url: string,
title?: string,
@@ -107,6 +107,10 @@ export const DELETE_ALL_PLAYLISTS = 'DELETE_ALL_PLAYLISTS';
// Remote Control
export const REMOTE_CONTROL_CHANGE_CHANNEL = 'REMOTE_CONTROL_CHANGE_CHANNEL';
// Display sleep: while a built-in video player is playing, the renderer asks
// the main process to hold a powerSaveBlocker so the screen stays awake
export const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE';
// Window controls (custom title bar on Windows/Linux)
export const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
export const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';