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

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