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

@@ -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;
}
}