mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(playback): release legacy WebKit picture-in-picture
This commit is contained in:
1 parent
d6da5c778c
commit
7c533811a9
7 files changed
+152
-4
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
+63
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user