fix(playback): release legacy WebKit picture-in-picture

This commit is contained in:
4gray committed 2026-09-05 12:45:37 +02:00
1 parent d6da5c778c
commit 7c533811a9
7 files changed
+152 -4

No files matched your search

+1 -1
View File
@@ -3,4 +3,4 @@ type: fix
area: playback
---
Switching channels or leaving playback now closes the old picture-in-picture window in HTML5, Video.js, and ArtPlayer even when shared player controls are disabled, preventing frozen or outdated video from remaining on top.
Switching channels or leaving playback now closes the old picture-in-picture window in HTML5, Video.js, and ArtPlayer even when shared player controls are disabled, preventing frozen or outdated video from remaining on top. This includes Safari’s legacy picture-in-picture mode.
+4 -1
View File
@@ -539,7 +539,10 @@ Key files:
Tech replacement also release exact-owned PiP through
`web-video-picture-in-picture-lifecycle.ts`, independent of the controls
preference. A one-shot listener on the retired video closes late native/vendor
entries without retaining the host or touching another video's PiP.
entries without retaining the host or touching another video's PiP. Legacy
WebKit presentation-mode PiP also returns the retired video to inline; its
presentation-change listener ignores fullscreen/inline events until a late
PiP entry consumes it.
Standard PiP shows the browser/OS video surface without Angular control
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
+4 -1
View File
@@ -1164,7 +1164,10 @@ engine` (restart required) or
Tech replacement also release exact-owned PiP through
`web-video-picture-in-picture-lifecycle.ts`, independent of the controls
preference. A one-shot listener on the retired video closes late native/vendor
entries without retaining the host or touching another video's PiP.
entries without retaining the host or touching another video's PiP. Legacy
WebKit presentation-mode PiP also returns the retired video to inline; its
presentation-change listener ignores fullscreen/inline events until a late
PiP entry consumes it.
Standard PiP shows the browser/OS video surface without Angular control
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
@@ -648,7 +648,10 @@ its previous Tech video when a reset replaces it. The shared
and leaves a one-shot listener on the retired video for an in-flight
native/vendor entry that completes after teardown. The listener captures only
the retired video, with no timer or document listener; a WeakSet makes repeated
release idempotent without retaining video elements. Shared controls retain
release idempotent without retaining video elements. Legacy Safari/WebKit
presentation-mode PiP is returned to `inline` on that exact video. Its
`webkitpresentationmodechanged` listener ignores fullscreen/inline changes and
is consumed only by the first late PiP entry. Shared controls retain
their existing generation-guarded pending-operation cleanup. Neither path
transfers PiP to a replacement video or closes another video's PiP.
@@ -95,6 +95,45 @@ describe('ArtPlayerComponent', () => {
}
);
it.each(['channel', 'destroy', 'late entry'])(
'closes legacy WebKit PiP on %s',
(transition) => {
createComponent({
url: 'https://example.test/one.mp4',
name: 'One',
});
const video = artPlayerInstances[0].video;
let mode =
transition === 'late entry' ? 'inline' : 'picture-in-picture';
const setMode = jest.fn((next: string) => {
mode = next;
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
});
Object.defineProperties(video, {
webkitPresentationMode: { get: () => mode },
webkitSetPresentationMode: { value: setMode },
});
if (transition === 'channel') {
fixture.componentRef.setInput('channel', {
url: 'https://example.test/two.mp4',
name: 'Two',
});
fixture.detectChanges();
} else {
fixture.destroy();
}
if (transition === 'late entry') {
mode = 'picture-in-picture';
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
}
expect(setMode).toHaveBeenCalledTimes(1);
expect(setMode).toHaveBeenCalledWith('inline');
expect(mode).toBe('inline');
}
);
it('emits a playback issue when the native video element reports an unsupported source', () => {
createComponent({
url: 'https://example.com/archive/movie.mkv',
@@ -53,6 +53,55 @@ describe('released video PiP lifecycle', () => {
}
});
it('returns an active WebKit PiP video to inline only once', () => {
const webkit = installWebKitPresentation(video, 'picture-in-picture');
releaseVideoPictureInPicture(video);
releaseVideoPictureInPicture(video);
expect(webkit.setMode).toHaveBeenCalledTimes(1);
expect(webkit.setMode).toHaveBeenCalledWith('inline');
expect(environment.exit).not.toHaveBeenCalled();
});
it('catches late WebKit PiP after unrelated presentation events', () => {
const webkit = installWebKitPresentation(video, 'fullscreen');
releaseVideoPictureInPicture(video);
webkit.change('inline');
expect(webkit.setMode).not.toHaveBeenCalled();
webkit.change('picture-in-picture');
expect(webkit.setMode).toHaveBeenCalledTimes(1);
expect(webkit.setMode).toHaveBeenCalledWith('inline');
// The retired-video listener is consumed by the first late PiP entry.
webkit.change('picture-in-picture');
expect(webkit.setMode).toHaveBeenCalledTimes(1);
});
it('leaves another WebKit video and standard PiP owner untouched', () => {
const webkit = installWebKitPresentation(video, 'inline');
const replacement = document.createElement('video');
const nextWebkit = installWebKitPresentation(
replacement,
'picture-in-picture'
);
environment.setActive(replacement);
releaseVideoPictureInPicture(video);
webkit.change('fullscreen');
expect(webkit.setMode).not.toHaveBeenCalled();
expect(nextWebkit.setMode).not.toHaveBeenCalled();
expect(environment.exit).not.toHaveBeenCalled();
expect(document.pictureInPictureElement).toBe(replacement);
});
it('contains WebKit exit errors while still releasing standard PiP', () => {
const webkit = installWebKitPresentation(video, 'picture-in-picture');
webkit.setMode.mockImplementation(() => {
throw new Error('WebKit exit failed');
});
environment.setActive(video);
expect(() => releaseVideoPictureInPicture(video)).not.toThrow();
expect(webkit.setMode).toHaveBeenCalledWith('inline');
expect(environment.exit).toHaveBeenCalledTimes(1);
});
it('allows teardown without a video or an exit API', () => {
environment.setActive(video);
environment.setExitAvailable(false);
@@ -70,3 +119,17 @@ describe('released video PiP lifecycle', () => {
await Promise.resolve();
});
});
function installWebKitPresentation(video: HTMLVideoElement, initial: string) {
let mode = initial;
const change = (next: string) => {
mode = next;
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
};
const setMode = jest.fn(change);
Object.defineProperties(video, {
webkitPresentationMode: { get: () => mode },
webkitSetPresentationMode: { value: setMode },
});
return { change, setMode };
}
@@ -1,5 +1,10 @@
const releasedVideos = new WeakSet<HTMLVideoElement>();
interface WebKitPictureInPictureVideo extends HTMLVideoElement {
readonly webkitPresentationMode?: string;
webkitSetPresentationMode?: (mode: 'inline') => void;
}
/** Release a legacy/native video that will never be used by this host again. */
export function releaseVideoPictureInPicture(
video: HTMLVideoElement | null | undefined
@@ -17,6 +22,38 @@ export function releaseVideoPictureInPicture(
{ once: true }
);
exitOwnedPictureInPicture(video);
releaseWebKitPictureInPicture(video);
}
function releaseWebKitPictureInPicture(
video: WebKitPictureInPictureVideo
): void {
if (typeof video.webkitSetPresentationMode !== 'function') return;
// WebKit uses one event for inline, fullscreen, and PiP. Only consume the
// retired-video listener when a late PiP entry actually arrives.
const onPresentationChange = () => {
if (video.webkitPresentationMode !== 'picture-in-picture') return;
video.removeEventListener(
'webkitpresentationmodechanged',
onPresentationChange
);
exitWebKitPictureInPicture(video);
};
video.addEventListener(
'webkitpresentationmodechanged',
onPresentationChange
);
exitWebKitPictureInPicture(video);
}
function exitWebKitPictureInPicture(video: WebKitPictureInPictureVideo): void {
try {
if (video.webkitPresentationMode === 'picture-in-picture') {
video.webkitSetPresentationMode?.('inline');
}
} catch {
// WebKit's synchronous API can reject presentation changes at teardown.
}
}
export function exitOwnedPictureInPicture(video: HTMLVideoElement): void {