diff --git a/packages/core/src/dom/presentation/fullscreen.ts b/packages/core/src/dom/presentation/fullscreen.ts index 8049d740..37d1641c 100644 --- a/packages/core/src/dom/presentation/fullscreen.ts +++ b/packages/core/src/dom/presentation/fullscreen.ts @@ -11,9 +11,10 @@ export function isFullscreenEnabled(): boolean { return true; } - // iOS Safari: check for webkitSupportsFullscreen on a test video + // iOS Safari: check for webkitEnterFullscreen on a test video. + // Unlike webkitSupportsFullscreen, this is available on detached elements. const video = document.createElement('video') as WebKitVideoElement; - return video.webkitSupportsFullscreen === true; + return isFunction(video.webkitEnterFullscreen); } /** Get the current fullscreen element from the document. */ @@ -55,10 +56,12 @@ export function isFullscreenElement(container: HTMLElement | null, media: HTMLMe * for platforms that only support video fullscreen (iOS Safari). */ export async function requestFullscreen(container: HTMLElement | null, media: HTMLMediaElement): Promise { + const doc = document as WebKitDocument; const video = media as WebKitVideoElement; - // Try container first (standard and WebKit APIs) - if (container) { + // Try container first, but only if the platform supports element-level fullscreen. + // iOS Safari has requestFullscreen on Element.prototype but it silently fails. + if (container && (doc.fullscreenEnabled || doc.webkitFullscreenEnabled)) { const el = container as WebKitFullscreenElement; if (isFunction(el.requestFullscreen)) { @@ -68,10 +71,6 @@ export async function requestFullscreen(container: HTMLElement | null, media: HT if (isFunction(el.webkitRequestFullscreen)) { return el.webkitRequestFullscreen(); } - - if (isFunction(el.webkitRequestFullScreen)) { - return el.webkitRequestFullScreen(); - } } // Fall back to media element (iOS Safari) @@ -89,30 +88,27 @@ export async function requestFullscreen(container: HTMLElement | null, media: HT } /** Exit fullscreen mode. */ -export async function exitFullscreen(): Promise { +export async function exitFullscreen(media?: HTMLMediaElement): Promise { const doc = document as WebKitDocument; - const video = getFullscreenElement() as WebKitVideoElement | null; - // Try standard API + // iOS Safari: use video element WebKit API first when it's actively in fullscreen. + if (media) { + const video = media as WebKitVideoElement; + if (isFunction(video.webkitExitFullscreen) && video.webkitDisplayingFullscreen) { + video.webkitExitFullscreen(); + return; + } + } + + // Standard API if (isFunction(doc.exitFullscreen)) { return doc.exitFullscreen(); } - // Try WebKit API + // WebKit document API (desktop Safari) if (isFunction(doc.webkitExitFullscreen)) { return doc.webkitExitFullscreen(); } - // Try older WebKit API - if (isFunction(doc.webkitCancelFullScreen)) { - return doc.webkitCancelFullScreen(); - } - - // iOS Safari video fullscreen - if (video && isFunction(video.webkitExitFullscreen)) { - video.webkitExitFullscreen(); - return; - } - // No-op if not in fullscreen (matches browser behavior) } diff --git a/packages/core/src/dom/presentation/pip.ts b/packages/core/src/dom/presentation/pip.ts index 4fa920a0..f47f5ac6 100644 --- a/packages/core/src/dom/presentation/pip.ts +++ b/packages/core/src/dom/presentation/pip.ts @@ -56,15 +56,16 @@ export async function requestPictureInPicture(media: HTMLMediaElement): Promise< const target = resolveMediaTarget(media); const video = target as HTMLVideoElement & WebKitVideoElement; - // Standard PiP API (only available on HTMLVideoElement) - if (isFunction(video.requestPictureInPicture)) { - await video.requestPictureInPicture(); + // iOS Safari: use WebKit presentation mode directly. + // The standard API may exist on the prototype but silently fail. + if (isFunction(video.webkitSetPresentationMode)) { + video.webkitSetPresentationMode('picture-in-picture'); return; } - // iOS Safari WebKit presentation mode - if (isFunction(video.webkitSetPresentationMode)) { - video.webkitSetPresentationMode('picture-in-picture'); + // Standard PiP API (only available on HTMLVideoElement) + if (isFunction(video.requestPictureInPicture)) { + await video.requestPictureInPicture(); return; } @@ -78,26 +79,20 @@ export async function requestPictureInPicture(media: HTMLMediaElement): Promise< * WebKit presentation mode. */ export async function exitPictureInPicture(media?: HTMLMediaElement): Promise { - // Standard PiP API - 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 + // iOS Safari: use WebKit presentation mode directly when active. if (media) { const target = resolveMediaTarget(media); const video = target as WebKitVideoElement; - const mode = video.webkitPresentationMode; - if (isFunction(video.webkitSetPresentationMode) && (!mode || mode === 'picture-in-picture')) { + if (isFunction(video.webkitSetPresentationMode) && video.webkitPresentationMode === 'picture-in-picture') { video.webkitSetPresentationMode('inline'); return; } } + // Standard PiP API + if (isFunction(document.exitPictureInPicture)) { + return document.exitPictureInPicture(); + } + // No-op if not in PiP (matches browser behavior) } diff --git a/packages/core/src/dom/presentation/types.ts b/packages/core/src/dom/presentation/types.ts index 845c7fc6..4c99a0a7 100644 --- a/packages/core/src/dom/presentation/types.ts +++ b/packages/core/src/dom/presentation/types.ts @@ -7,8 +7,6 @@ export interface WebKitVideoElement extends HTMLVideoElement { webkitDisplayingFullscreen?: boolean; /** Current WebKit presentation mode (iOS Safari). */ webkitPresentationMode?: WebKitPresentationMode; - /** Whether WebKit fullscreen is supported (iOS Safari). */ - webkitSupportsFullscreen?: boolean; /** Enter fullscreen using WebKit API (iOS Safari). */ webkitEnterFullscreen?: () => void; /** Exit fullscreen using WebKit API (iOS Safari). */ @@ -21,8 +19,6 @@ export interface WebKitVideoElement extends HTMLVideoElement { export interface WebKitFullscreenElement extends Element { /** Request fullscreen using WebKit API (Safari). */ webkitRequestFullscreen?: () => Promise; - /** Request fullscreen using WebKit API (older Safari). */ - webkitRequestFullScreen?: () => Promise; } /** Extended Document with WebKit fullscreen vendor APIs. */ @@ -33,6 +29,4 @@ export interface WebKitDocument extends Document { webkitFullscreenEnabled?: boolean; /** Exit fullscreen (WebKit). */ webkitExitFullscreen?: () => Promise; - /** Exit fullscreen (older WebKit). */ - webkitCancelFullScreen?: () => Promise; } diff --git a/packages/core/src/dom/store/features/fullscreen.ts b/packages/core/src/dom/store/features/fullscreen.ts index 1227752a..29e8f108 100644 --- a/packages/core/src/dom/store/features/fullscreen.ts +++ b/packages/core/src/dom/store/features/fullscreen.ts @@ -29,7 +29,8 @@ export const fullscreenFeature = definePlayerFeature({ }, async exitFullscreen() { - return exitFullscreen(); + const { media } = target(); + return exitFullscreen(media); }, }), diff --git a/packages/core/src/dom/store/features/tests/fullscreen.test.ts b/packages/core/src/dom/store/features/tests/fullscreen.test.ts index b0492cdb..bdbff711 100644 --- a/packages/core/src/dom/store/features/tests/fullscreen.test.ts +++ b/packages/core/src/dom/store/features/tests/fullscreen.test.ts @@ -1,6 +1,7 @@ import { createStore } from '@videojs/store'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PlayerTarget } from '../../../media/types'; +import type { WebKitVideoElement } from '../../../presentation/types'; import { createMockVideo } from '../../../tests/test-helpers'; import { fullscreenFeature } from '../fullscreen'; @@ -63,6 +64,63 @@ describe('fullscreenFeature', () => { expect(store.state.fullscreenAvailability).toBe('unsupported'); }); + it('detects fullscreen availability via webkitEnterFullscreen (iOS Safari)', () => { + Object.defineProperty(document, 'fullscreenEnabled', { + value: false, + writable: true, + configurable: true, + }); + + // Simulate iOS Safari: webkitEnterFullscreen on video prototype + const proto = HTMLVideoElement.prototype as WebKitVideoElement; + const original = proto.webkitEnterFullscreen; + proto.webkitEnterFullscreen = () => {}; + + const video = createMockVideo(); + const store = createStore()(fullscreenFeature); + store.attach({ media: video, container: null }); + + expect(store.state.fullscreenAvailability).toBe('available'); + + if (original) { + proto.webkitEnterFullscreen = original; + } else { + delete proto.webkitEnterFullscreen; + } + }); + + it('syncs fullscreen on webkitpresentationmodechanged event (iOS Safari)', () => { + Object.defineProperty(document, 'fullscreenEnabled', { + value: false, + writable: true, + configurable: true, + }); + + const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement; + video.webkitPresentationMode = 'inline'; + video.webkitDisplayingFullscreen = false; + + const container = document.createElement('div'); + const store = createStore()(fullscreenFeature); + store.attach({ media: video, container }); + + expect(store.state.fullscreen).toBe(false); + + // Simulate entering fullscreen via WebKit presentation mode + video.webkitPresentationMode = 'fullscreen'; + video.webkitDisplayingFullscreen = true; + video.dispatchEvent(new Event('webkitpresentationmodechanged')); + + expect(store.state.fullscreen).toBe(true); + + // Simulate exiting + video.webkitPresentationMode = 'inline'; + video.webkitDisplayingFullscreen = false; + video.dispatchEvent(new Event('webkitpresentationmodechanged')); + + expect(store.state.fullscreen).toBe(false); + }); + it('updates fullscreen on fullscreenchange event', () => { Object.defineProperty(document, 'fullscreenEnabled', { value: true, @@ -129,6 +187,12 @@ describe('fullscreenFeature', () => { describe('actions', () => { it('requestFullscreen() calls requestFullscreen on container', async () => { + Object.defineProperty(document, 'fullscreenEnabled', { + value: true, + writable: true, + configurable: true, + }); + const video = createMockVideo(); const container = document.createElement('div'); container.requestFullscreen = vi.fn().mockResolvedValue(undefined); @@ -168,10 +232,54 @@ describe('fullscreenFeature', () => { document.exitFullscreen = originalExit; }); + + it('requestFullscreen() uses webkitEnterFullscreen when element fullscreen is unsupported (iOS Safari)', async () => { + Object.defineProperty(document, 'fullscreenEnabled', { + value: false, + writable: true, + configurable: true, + }); + + const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement; + video.webkitEnterFullscreen = vi.fn(); + const container = document.createElement('div'); + + const store = createStore()(fullscreenFeature); + store.attach({ media: video, container }); + + await store.requestFullscreen(); + + expect(video.webkitEnterFullscreen).toHaveBeenCalled(); + }); + + it('exitFullscreen() uses webkitExitFullscreen first when video is in WebKit fullscreen (iOS Safari)', async () => { + const originalExit = document.exitFullscreen; + document.exitFullscreen = vi.fn(); + + const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement; + video.webkitExitFullscreen = vi.fn(); + video.webkitDisplayingFullscreen = true; + + const store = createStore()(fullscreenFeature); + store.attach({ media: video, container: null }); + + await store.exitFullscreen(); + + expect(video.webkitExitFullscreen).toHaveBeenCalled(); + expect(document.exitFullscreen).not.toHaveBeenCalled(); + + document.exitFullscreen = originalExit; + }); }); describe('transitions', () => { it('requestFullscreen() exits PiP first if active', async () => { + Object.defineProperty(document, 'fullscreenEnabled', { + value: true, + writable: true, + configurable: true, + }); + const originalExit = document.exitPictureInPicture; document.exitPictureInPicture = vi.fn().mockResolvedValue(undefined); @@ -198,6 +306,12 @@ describe('fullscreenFeature', () => { }); it('requestFullscreen() does not exit PiP if not active', async () => { + Object.defineProperty(document, 'fullscreenEnabled', { + value: true, + writable: true, + configurable: true, + }); + const originalExit = document.exitPictureInPicture; document.exitPictureInPicture = vi.fn().mockResolvedValue(undefined); 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 5572df4d..36e882c9 100644 --- a/packages/core/src/dom/store/features/tests/pip.test.ts +++ b/packages/core/src/dom/store/features/tests/pip.test.ts @@ -1,6 +1,7 @@ import { createStore } from '@videojs/store'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PlayerTarget } from '../../../media/types'; +import type { WebKitVideoElement } from '../../../presentation/types'; import { createMockVideo } from '../../../tests/test-helpers'; import { pipFeature } from '../pip'; @@ -83,6 +84,28 @@ describe('pipFeature', () => { expect(store.state.pip).toBe(false); }); + it('syncs pip on webkitpresentationmodechanged event (iOS Safari)', () => { + const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement; + video.webkitPresentationMode = 'inline'; + + const store = createStore()(pipFeature); + store.attach({ media: video, container: null }); + + expect(store.state.pip).toBe(false); + + // Simulate entering PiP via WebKit presentation mode + video.webkitPresentationMode = 'picture-in-picture'; + video.dispatchEvent(new Event('webkitpresentationmodechanged')); + + expect(store.state.pip).toBe(true); + + // Simulate exiting + video.webkitPresentationMode = 'inline'; + video.dispatchEvent(new Event('webkitpresentationmodechanged')); + + expect(store.state.pip).toBe(false); + }); + it('syncs pip when media element proxies to an internal target video', () => { Object.defineProperty(document, 'pictureInPictureEnabled', { value: true, @@ -130,6 +153,20 @@ describe('pipFeature', () => { expect(video.requestPictureInPicture).toHaveBeenCalled(); }); + it('requestPictureInPicture() uses webkitSetPresentationMode first when available (iOS Safari)', async () => { + const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement; + video.requestPictureInPicture = vi.fn().mockResolvedValue({}); + video.webkitSetPresentationMode = vi.fn(); + + const store = createStore()(pipFeature); + store.attach({ media: video, container: null }); + + await store.requestPictureInPicture(); + + expect(video.webkitSetPresentationMode).toHaveBeenCalledWith('picture-in-picture'); + expect(video.requestPictureInPicture).not.toHaveBeenCalled(); + }); + it('exitPictureInPicture() calls document.exitPictureInPicture', async () => { const originalExit = document.exitPictureInPicture; document.exitPictureInPicture = vi.fn().mockResolvedValue(undefined);