fix(core): resolve pip state against media target (#883)

This commit is contained in:
rahim
2026-03-10 21:35:14 -07:00
committed by GitHub
parent abf8753fe6
commit 45a312e016
2 changed files with 81 additions and 8 deletions
+26 -8
View File
@@ -2,6 +2,15 @@ import { isFunction } from '@videojs/utils/predicate';
import type { WebKitVideoElement } from './types';
type MediaWithTarget = HTMLMediaElement & {
target?: unknown;
};
function resolveMediaTarget(media: HTMLMediaElement): HTMLMediaElement {
const target = (media as MediaWithTarget).target;
return target instanceof HTMLMediaElement ? target : media;
}
/**
* Check if Picture-in-Picture is supported on this platform.
*
@@ -25,13 +34,15 @@ export function isPictureInPictureEnabled(): boolean {
* Check if Picture-in-Picture is currently active for a media element.
*/
export function isPictureInPictureElement(media: HTMLMediaElement): boolean {
const target = resolveMediaTarget(media);
// Standard PiP API
if (document.pictureInPictureElement === media) {
if (document.pictureInPictureElement === target) {
return true;
}
// iOS Safari WebKit presentation mode
const video = media as WebKitVideoElement;
const video = target as WebKitVideoElement;
return video.webkitPresentationMode === 'picture-in-picture';
}
@@ -42,7 +53,8 @@ export function isPictureInPictureElement(media: HTMLMediaElement): boolean {
* WebKit presentation mode.
*/
export async function requestPictureInPicture(media: HTMLMediaElement): Promise<void> {
const video = media as HTMLVideoElement & WebKitVideoElement;
const target = resolveMediaTarget(media);
const video = target as HTMLVideoElement & WebKitVideoElement;
// Standard PiP API (only available on HTMLVideoElement)
if (isFunction(video.requestPictureInPicture)) {
@@ -67,15 +79,21 @@ export async function requestPictureInPicture(media: HTMLMediaElement): Promise<
*/
export async function exitPictureInPicture(media?: HTMLMediaElement): Promise<void> {
// Standard PiP API
if (document.pictureInPictureElement && isFunction(document.exitPictureInPicture)) {
await document.exitPictureInPicture();
return;
if (isFunction(document.exitPictureInPicture)) {
try {
await document.exitPictureInPicture();
return;
} catch {
// Some engines expose a partial standard API. Fall back to WebKit mode.
}
}
// iOS Safari WebKit presentation mode
if (media) {
const video = media as WebKitVideoElement;
if (video.webkitPresentationMode === 'picture-in-picture' && isFunction(video.webkitSetPresentationMode)) {
const target = resolveMediaTarget(media);
const video = target as WebKitVideoElement;
const mode = video.webkitPresentationMode;
if (isFunction(video.webkitSetPresentationMode) && (!mode || mode === 'picture-in-picture')) {
video.webkitSetPresentationMode('inline');
return;
}
@@ -82,6 +82,39 @@ describe('pipFeature', () => {
expect(store.state.pip).toBe(false);
});
it('syncs pip when media element proxies to an internal target video', () => {
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: true,
writable: true,
configurable: true,
});
const targetVideo = createMockVideo();
const mediaHost = document.createElement('div') as unknown as HTMLVideoElement & {
target: HTMLVideoElement;
};
Object.defineProperty(mediaHost, 'target', {
value: targetVideo,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: mediaHost, container: null });
expect(store.state.pip).toBe(false);
Object.defineProperty(document, 'pictureInPictureElement', {
value: targetVideo,
writable: true,
configurable: true,
});
mediaHost.dispatchEvent(new Event('enterpictureinpicture'));
expect(store.state.pip).toBe(true);
});
});
describe('actions', () => {
@@ -119,6 +152,28 @@ describe('pipFeature', () => {
document.exitPictureInPicture = originalExit;
});
it('exitPictureInPicture() calls document.exitPictureInPicture even without pictureInPictureElement', async () => {
const originalExit = document.exitPictureInPicture;
document.exitPictureInPicture = vi.fn().mockResolvedValue(undefined);
const video = createMockVideo();
Object.defineProperty(document, 'pictureInPictureElement', {
value: null,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container: null });
await store.exitPictureInPicture();
expect(document.exitPictureInPicture).toHaveBeenCalled();
document.exitPictureInPicture = originalExit;
});
});
describe('transitions', () => {