fix(core): improve fullscreen and pip webkit fallback handling (#999)

This commit is contained in:
rahim
2026-03-18 01:35:53 -07:00
committed by GitHub
parent 12f582ec1a
commit a05e8f20af
6 changed files with 186 additions and 49 deletions
@@ -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<void> {
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<void> {
export async function exitFullscreen(media?: HTMLMediaElement): Promise<void> {
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)
}
+14 -19
View File
@@ -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<void> {
// 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)
}
@@ -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<void>;
/** Request fullscreen using WebKit API (older Safari). */
webkitRequestFullScreen?: () => Promise<void>;
}
/** Extended Document with WebKit fullscreen vendor APIs. */
@@ -33,6 +29,4 @@ export interface WebKitDocument extends Document {
webkitFullscreenEnabled?: boolean;
/** Exit fullscreen (WebKit). */
webkitExitFullscreen?: () => Promise<void>;
/** Exit fullscreen (older WebKit). */
webkitCancelFullScreen?: () => Promise<void>;
}
@@ -29,7 +29,8 @@ export const fullscreenFeature = definePlayerFeature({
},
async exitFullscreen() {
return exitFullscreen();
const { media } = target();
return exitFullscreen(media);
},
}),
@@ -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<PlayerTarget>()(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<PlayerTarget>()(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<PlayerTarget>()(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<PlayerTarget>()(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);
@@ -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<PlayerTarget>()(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<PlayerTarget>()(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);