From 45a312e0162bc66983eb7f41701a1bf28019736f Mon Sep 17 00:00:00 2001 From: rahim Date: Tue, 10 Mar 2026 21:35:14 -0700 Subject: [PATCH] fix(core): resolve pip state against media target (#883) --- packages/core/src/dom/presentation/pip.ts | 34 +++++++++--- .../src/dom/store/features/tests/pip.test.ts | 55 +++++++++++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/packages/core/src/dom/presentation/pip.ts b/packages/core/src/dom/presentation/pip.ts index ba0a502c..4fa920a0 100644 --- a/packages/core/src/dom/presentation/pip.ts +++ b/packages/core/src/dom/presentation/pip.ts @@ -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 { - 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 { // 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; } diff --git a/packages/core/src/dom/store/features/tests/pip.test.ts b/packages/core/src/dom/store/features/tests/pip.test.ts index 0f617c14..5572df4d 100644 --- a/packages/core/src/dom/store/features/tests/pip.test.ts +++ b/packages/core/src/dom/store/features/tests/pip.test.ts @@ -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()(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()(pipFeature); + store.attach({ media: video, container: null }); + + await store.exitPictureInPicture(); + + expect(document.exitPictureInPicture).toHaveBeenCalled(); + + document.exitPictureInPicture = originalExit; + }); }); describe('transitions', () => {