mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
fix(core): improve fullscreen and pip webkit fallback handling (#999)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user