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

* 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>

* fix(playback): withdraw the keep-awake vote when the renderer crashes

A crash emits render-process-gone while the WebContents object stays
alive, so the destroyed listener alone missed it: without a follow-up
reload the display stayed pinned awake. Review finding by Codex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): keep the display lock for picture-in-picture playback

Minimizing the window hides the document but leaves the PiP surface on
screen, so the visibility gate was releasing the lock mid-watch. A
tracked playing video that owns document.pictureInPictureElement now
counts as visible playback, and PiP enter/leave events resynchronize
the gate. Review finding by Codex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): re-evaluate the wake lock after a rejection masked a state change

In the PWA path a hidden-visible round-trip (or pause/resume) while
wakeLock.request() was pending got swallowed by the in-flight guard; if
that request then rejected, only the flag was cleared and a continuously
playing visible video sat without a wake lock until the next unrelated
event. State changes arriving mid-flight now queue one re-evaluation on
rejection; permanent denials still don't loop because nothing queues a
retry without a fresh interleaved change. Review finding by Codex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(playback): mirror the display-sleep contract into AGENTS.md

AGENTS.md carries its own playback sections (radio, shared controls,
PiP), so the keep-awake contract belongs there too. Review finding by
Codex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-12 00:23:03 +02:00
1 parent 6041233f41
commit dded17010d
12 files changed
+900

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.
+21
View File
@@ -446,6 +446,27 @@ Key files:
- Canonical docs: `docs/architecture/player-controls-contract.md` and
`docs/architecture/embedded-mpv-native.md`
## 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 (or the playing video is in
picture-in-picture — the PiP surface survives a minimized window), holds a
display-sleep lock.
- Electron: a main-process `powerSaveBlocker` behind
`window.electron.setPlaybackKeepAwake`
(`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`);
the renderer's vote is auto-cleared on renderer reload, crash
(`render-process-gone`), or destruction. PWA: the Screen Wake Lock API,
re-requested after browser auto-release; state changes masked by an
in-flight `request()` queue one re-evaluation on rejection.
- Radio's `<audio>` deliberately never blocks display sleep. Embedded MPV
holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC
inhibit the screensaver themselves.
## Linux Embedded MPV Packaging
- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM,
+15
View File
@@ -933,6 +933,21 @@ 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 (or the playing video is in
picture-in-picture — the PiP surface survives a minimized window), 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,164 @@
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 when the renderer process crashes', () => {
const stub = createSenderStub(1);
setPlaybackKeepAwake(stub.sender, true);
// A crash emits render-process-gone while the WebContents object
// stays alive; without a reload no other lifetime event follows.
stub.emit('render-process-gone', {}, { reason: 'crashed' });
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('render-process-gone')).toBe(1);
expect(stub.listenerCount('did-start-navigation')).toBe(1);
setPlaybackKeepAwake(stub.sender, false);
expect(stub.listenerCount('destroyed')).toBe(0);
expect(stub.listenerCount('render-process-gone')).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,108 @@
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/crash/navigation
* listeners that withdraw its vote — a crash emits `render-process-gone`
* while the WebContents object stays alive, so `destroyed` alone would miss
* it when no reload follows.
*/
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('render-process-gone', clear);
sender.on('did-start-navigation', onNavigation);
senderCleanups.set(senderId, () => {
sender.off('destroyed', clear);
sender.off('render-process-gone', 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,302 @@
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);
});
const setPictureInPictureElement = (element: Element | null) => {
Object.defineProperty(document, 'pictureInPictureElement', {
configurable: true,
get: () => element,
});
};
afterEach(() => {
service.stop();
video.remove();
delete (window as ElectronWindow).electron;
delete (document as { visibilityState?: unknown }).visibilityState;
delete (document as { pictureInPictureElement?: unknown })
.pictureInPictureElement;
});
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('keeps the lock while a hidden window plays video in picture-in-picture', () => {
video.dispatchEvent(new Event('playing'));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(true);
setPictureInPictureElement(video);
video.dispatchEvent(new Event('enterpictureinpicture'));
setVisibility('hidden');
// The PiP surface stays on screen after minimizing the window.
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(true);
expect(setPlaybackKeepAwake).toHaveBeenCalledTimes(1);
});
it('releases the lock when PiP closes while the window is hidden', () => {
video.dispatchEvent(new Event('playing'));
setPictureInPictureElement(video);
video.dispatchEvent(new Event('enterpictureinpicture'));
setVisibility('hidden');
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(true);
setPictureInPictureElement(null);
video.dispatchEvent(new Event('leavepictureinpicture'));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(false);
});
it('does not let a paused PiP video hold the lock', () => {
video.dispatchEvent(new Event('playing'));
setPictureInPictureElement(video);
video.dispatchEvent(new Event('enterpictureinpicture'));
setVisibility('hidden');
video.dispatchEvent(new Event('pause'));
expect(setPlaybackKeepAwake).toHaveBeenLastCalledWith(false);
});
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('re-evaluates after a rejection that masked a state change', async () => {
let rejectRequest: ((reason: Error) => void) | undefined;
request.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectRequest = reject;
})
);
video.dispatchEvent(new Event('playing'));
// Hidden→visible round-trip while request() is still pending:
// both sync() calls are swallowed by the in-flight guard, and
// the pending request rejects because of the hidden moment.
setVisibility('hidden');
setVisibility('visible');
rejectRequest?.(new Error('document was hidden'));
await flush();
// The masked state change must trigger a fresh request — the
// video is still playing in a visible document.
expect(request).toHaveBeenCalledTimes(2);
});
it('does not retry a plain rejection with no interleaved change', async () => {
request.mockRejectedValueOnce(new Error('denied'));
video.dispatchEvent(new Event('playing'));
await flush();
expect(request).toHaveBeenCalledTimes(1);
});
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,252 @@
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 exception is a
* tracked video in picture-in-picture — hiding the window keeps the PiP
* surface on screen, so it counts as visible playback (and PiP enter/leave
* resynchronizes the gate). 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 wakeLockRetryQueued = false;
private readonly onPlaying = (event: Event) => {
const target = event.target;
if (target instanceof HTMLVideoElement) {
this.trackVideo(target);
}
};
private readonly onVisibilityChange = () => {
this.sync();
};
// PiP events don't bubble either; capture reaches them from any video.
private readonly onPictureInPictureChange = () => {
this.sync();
};
start(): void {
if (this.started) {
return;
}
this.started = true;
document.addEventListener('playing', this.onPlaying, true);
document.addEventListener('visibilitychange', this.onVisibilityChange);
document.addEventListener(
'enterpictureinpicture',
this.onPictureInPictureChange,
true
);
document.addEventListener(
'leavepictureinpicture',
this.onPictureInPictureChange,
true
);
}
stop(): void {
if (!this.started) {
return;
}
this.started = false;
document.removeEventListener('playing', this.onPlaying, true);
document.removeEventListener(
'visibilitychange',
this.onVisibilityChange
);
document.removeEventListener(
'enterpictureinpicture',
this.onPictureInPictureChange,
true
);
document.removeEventListener(
'leavepictureinpicture',
this.onPictureInPictureChange,
true
);
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.shouldHoldLock();
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) {
return;
}
if (this.wakeLockRequestInFlight) {
// A state change arrived while request() is still pending (e.g.
// hidden→visible round-trip). The pending request may reject
// because of the moment it was processed in, so remember to
// re-evaluate once it settles instead of dropping this signal.
this.wakeLockRetryQueued = true;
return;
}
const wakeLock = (navigator as WakeLockNavigator).wakeLock;
if (!wakeLock) {
return;
}
this.wakeLockRequestInFlight = true;
wakeLock
.request('screen')
.then((sentinel) => {
this.wakeLockRequestInFlight = false;
// The resolve path re-reads current state below, which
// covers whatever change queued the retry.
this.wakeLockRetryQueued = 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;
}
});
if (!this.shouldHoldLock()) {
sentinel.release().catch(() => undefined);
return;
}
this.wakeLock = sentinel;
})
.catch(() => {
this.wakeLockRequestInFlight = false;
if (this.wakeLockRetryQueued) {
// A state change was masked by the in-flight request —
// re-evaluate now. Permanent denials don't loop: without
// a fresh interleaved change nothing queues a retry.
this.wakeLockRetryQueued = false;
this.sync();
}
// Otherwise: denied (battery saver, hidden document, …) — a
// later `playing` or visibility event retries via sync().
});
}
/**
* A hidden document normally releases the lock, but a tracked playing
* video in picture-in-picture stays on screen after the window is
* minimized — that is still watched playback.
*/
private shouldHoldLock(): boolean {
if (this.playingVideos.size === 0) {
return false;
}
if (document.visibilityState === 'visible') {
return true;
}
const pipElement = (
document as { pictureInPictureElement?: Element | null }
).pictureInPictureElement;
return (
pipElement instanceof HTMLVideoElement &&
this.playingVideos.has(pipElement)
);
}
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';