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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-10 22:43:26 +02:00
1 parent f97c5e6923
commit 64f150c91c
3 files changed
+101 -11

No files matched your search

+3 -1
View File
@@ -938,7 +938,9 @@ app as a real argument, so it is not an option.
`<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, holds a display-sleep lock: in
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`;
@@ -27,11 +27,20 @@ describe('PlaybackKeepAwakeService', () => {
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', () => {
@@ -116,6 +125,41 @@ describe('PlaybackKeepAwakeService', () => {
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'));
@@ -38,9 +38,12 @@ const RELEASE_EVENTS = ['pause', 'ended', 'emptied', 'error'] as const;
* 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.
* 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 {
@@ -63,6 +66,11 @@ export class PlaybackKeepAwakeService {
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;
@@ -70,6 +78,16 @@ export class PlaybackKeepAwakeService {
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 {
@@ -82,6 +100,16 @@ export class PlaybackKeepAwakeService {
'visibilitychange',
this.onVisibilityChange
);
document.removeEventListener(
'enterpictureinpicture',
this.onPictureInPictureChange,
true
);
document.removeEventListener(
'leavepictureinpicture',
this.onPictureInPictureChange,
true
);
for (const video of [...this.playingVideos]) {
this.untrackVideo(video);
}
@@ -115,9 +143,7 @@ export class PlaybackKeepAwakeService {
}
private sync(): void {
const shouldBlock =
this.playingVideos.size > 0 &&
document.visibilityState === 'visible';
const shouldBlock = this.shouldHoldLock();
const bridge = this.getBridge();
if (bridge?.setPlaybackKeepAwake) {
@@ -158,10 +184,7 @@ export class PlaybackKeepAwakeService {
this.wakeLock = null;
}
});
const stillWanted =
this.playingVideos.size > 0 &&
document.visibilityState === 'visible';
if (!stillWanted) {
if (!this.shouldHoldLock()) {
sentinel.release().catch(() => undefined);
return;
}
@@ -174,6 +197,27 @@ export class PlaybackKeepAwakeService {
});
}
/**
* 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) {