mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
6041233f41
commit
dded17010d
12 files changed
+900
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user