mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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:
1 parent
d73551d780
commit
7349ed6511
11 files changed
+721
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user