mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
1 parent
f97c5e6923
commit
64f150c91c
3 files changed
+101
-11
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user