diff --git a/packages/core/src/core/media/types.ts b/packages/core/src/core/media/types.ts index fa11cf71..226b249f 100644 --- a/packages/core/src/core/media/types.ts +++ b/packages/core/src/core/media/types.ts @@ -174,11 +174,15 @@ export interface MediaTextTrackCapability { } export interface MediaFullscreenCapability { - requestFullscreen(): Promise; + readonly isFullscreen: boolean; + requestFullscreen(): Promise; + exitFullscreen(): Promise; } export interface MediaPictureInPictureCapability { + readonly isPictureInPicture: boolean; requestPictureInPicture(): Promise; + exitPictureInPicture(): Promise; } export interface MediaRemotePlaybackCapability { diff --git a/packages/core/src/dom/media/tests/video-host.test.ts b/packages/core/src/dom/media/tests/video-host.test.ts new file mode 100644 index 00000000..2323bafc --- /dev/null +++ b/packages/core/src/dom/media/tests/video-host.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { WebKitDocument, WebKitVideoElement } from '../../presentation/types'; +import { HTMLVideoElementHost } from '../video-host'; + +afterEach(() => { + document.body.innerHTML = ''; + Object.defineProperty(document, 'pictureInPictureElement', { + value: null, + writable: true, + configurable: true, + }); + Object.defineProperty(document, 'fullscreenElement', { + value: null, + writable: true, + configurable: true, + }); + Object.defineProperty(document, 'webkitFullscreenElement', { + value: null, + writable: true, + configurable: true, + }); +}); + +describe('HTMLVideoElementHost', () => { + describe('isPictureInPicture', () => { + it('returns false when no target is attached', () => { + const host = new HTMLVideoElementHost(); + expect(host.isPictureInPicture).toBe(false); + }); + + it('returns true when target is the PiP element', () => { + const video = document.createElement('video'); + const host = new HTMLVideoElementHost(); + host.attach(video); + + Object.defineProperty(document, 'pictureInPictureElement', { + value: video, + writable: true, + configurable: true, + }); + + expect(host.isPictureInPicture).toBe(true); + }); + + it('reflects target swaps', () => { + const a = document.createElement('video'); + const b = document.createElement('video'); + const host = new HTMLVideoElementHost(); + + Object.defineProperty(document, 'pictureInPictureElement', { + value: b, + writable: true, + configurable: true, + }); + + host.attach(a); + expect(host.isPictureInPicture).toBe(false); + + host.detach(); + host.attach(b); + expect(host.isPictureInPicture).toBe(true); + }); + + it('detects WebKit picture-in-picture presentation mode', () => { + const video = document.createElement('video') as HTMLVideoElement & WebKitVideoElement; + video.webkitPresentationMode = 'picture-in-picture'; + + const host = new HTMLVideoElementHost(); + host.attach(video); + + expect(host.isPictureInPicture).toBe(true); + }); + + it('returns false when WebKit presentation mode is inline', () => { + const video = document.createElement('video') as HTMLVideoElement & WebKitVideoElement; + video.webkitPresentationMode = 'inline'; + + const host = new HTMLVideoElementHost(); + host.attach(video); + + expect(host.isPictureInPicture).toBe(false); + }); + }); + + describe('isFullscreen', () => { + it('returns false when no target is attached', () => { + const host = new HTMLVideoElementHost(); + expect(host.isFullscreen).toBe(false); + }); + + it('returns true when document.fullscreenElement matches the target', () => { + const video = document.createElement('video'); + const host = new HTMLVideoElementHost(); + host.attach(video); + + Object.defineProperty(document, 'fullscreenElement', { + value: video, + writable: true, + configurable: true, + }); + + expect(host.isFullscreen).toBe(true); + }); + + it('returns true when webkitFullscreenElement matches the target', () => { + const video = document.createElement('video'); + const host = new HTMLVideoElementHost(); + host.attach(video); + + Object.defineProperty(document as WebKitDocument, 'webkitFullscreenElement', { + value: video, + writable: true, + configurable: true, + }); + + expect(host.isFullscreen).toBe(true); + }); + + it('returns false when fullscreen element is something else', () => { + const video = document.createElement('video'); + const other = document.createElement('div'); + const host = new HTMLVideoElementHost(); + host.attach(video); + + Object.defineProperty(document, 'fullscreenElement', { + value: other, + writable: true, + configurable: true, + }); + + expect(host.isFullscreen).toBe(false); + }); + + it('detects WebKit fullscreen presentation mode', () => { + const video = document.createElement('video') as HTMLVideoElement & WebKitVideoElement; + video.webkitPresentationMode = 'fullscreen'; + + const host = new HTMLVideoElementHost(); + host.attach(video); + + expect(host.isFullscreen).toBe(true); + }); + + it('returns false when WebKit presentation mode is inline', () => { + const video = document.createElement('video') as HTMLVideoElement & WebKitVideoElement; + video.webkitPresentationMode = 'inline'; + + const host = new HTMLVideoElementHost(); + host.attach(video); + + expect(host.isFullscreen).toBe(false); + }); + }); +}); diff --git a/packages/core/src/dom/media/video-host.ts b/packages/core/src/dom/media/video-host.ts index 8a70aa74..b08d4ddb 100644 --- a/packages/core/src/dom/media/video-host.ts +++ b/packages/core/src/dom/media/video-host.ts @@ -1,5 +1,6 @@ +import { isFunction } from '@videojs/utils/predicate'; import type { Video, VideoEvents } from '../../core/media/types'; -import type { WebKitPresentationMode, WebKitVideoElement } from '../presentation/types'; +import type { WebKitDocument, WebKitPresentationMode, WebKitVideoElement } from '../presentation/types'; import { HTMLMediaElementHost } from './media-host'; export class HTMLVideoElementHost extends HTMLMediaElementHost implements Video { @@ -11,31 +12,47 @@ export class HTMLVideoElementHost extends HTMLMediaElementHost void) | undefined { + const target = this.target as unknown as WebKitVideoElement | null; + const fn = target?.webkitSetPresentationMode; + return isFunction(fn) ? fn.bind(target) : undefined; + } + + get isPictureInPicture(): boolean { + return ( + (!!this.target && globalThis.document?.pictureInPictureElement === this.target) || + this.webkitPresentationMode === 'picture-in-picture' + ); + } + + get isFullscreen(): boolean { + if (!this.target) return false; + if (this.webkitPresentationMode === 'fullscreen') return true; + const doc = globalThis.document as WebKitDocument; + return doc?.fullscreenElement === this.target || doc?.webkitFullscreenElement === this.target; + } + + async requestPictureInPicture() { + if (!this.target) return Promise.reject(); + return this.target.requestPictureInPicture(); + } + + async exitPictureInPicture() { + if (!this.target) return Promise.reject(); + return globalThis.document?.exitPictureInPicture(); } requestFullscreen() { - return this.target?.requestFullscreen() ?? Promise.reject(); + if (!this.target) return Promise.reject(); + return this.target.requestFullscreen(); } - webkitEnterFullscreen() { - return (this.target as unknown as WebKitVideoElement | null)?.webkitEnterFullscreen?.(); - } - - webkitExitFullscreen() { - return (this.target as unknown as WebKitVideoElement | null)?.webkitExitFullscreen?.(); - } - - webkitSetPresentationMode(mode: WebKitPresentationMode) { - return (this.target as unknown as WebKitVideoElement | null)?.webkitSetPresentationMode?.(mode); + exitFullscreen() { + if (!this.target) return Promise.reject(); + return globalThis.document?.exitFullscreen(); } } diff --git a/packages/core/src/dom/presentation/fullscreen.ts b/packages/core/src/dom/presentation/fullscreen.ts index f75f01fa..c0ca8022 100644 --- a/packages/core/src/dom/presentation/fullscreen.ts +++ b/packages/core/src/dom/presentation/fullscreen.ts @@ -1,47 +1,57 @@ import { isFunction } from '@videojs/utils/predicate'; - +import type { MediaFullscreenCapability } from '../../core/media/types'; import type { WebKitDocument, WebKitFullscreenElement, WebKitVideoElement } from './types'; -export function isFullscreenEnabled(): boolean { +export function isFullscreenEnabled() { const doc = document as WebKitDocument; - if (doc.fullscreenEnabled || doc.webkitFullscreenEnabled) { return true; } const video = document.createElement('video') as WebKitVideoElement; - return isFunction(video.webkitEnterFullscreen); + return isFunction(video.webkitSetPresentationMode); } -export function getFullscreenElement(): Element | null { +export function getFullscreenElement() { const doc = document as WebKitDocument; return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null; } -export function isFullscreenElement(container: HTMLElement | null, media: EventTarget): boolean { - if (media instanceof HTMLMediaElement) { - const video = media as WebKitVideoElement; - if (video.webkitDisplayingFullscreen && video.webkitPresentationMode === 'fullscreen') { - return true; - } +function matchesFullscreen(element: EventTarget | null) { + if (!(element instanceof Element)) return false; + try { + return element.matches(':fullscreen'); + } catch { + return false; } - - const target = container ?? media; - - if (getFullscreenElement() === target) return true; - - if (target instanceof Element) { - try { - return target.matches(':fullscreen'); - } catch { - return false; - } - } - - return false; } -export async function requestFullscreen(container: HTMLElement | null, media: EventTarget): Promise { +export function isFullscreen(container: HTMLElement | null, media: EventTarget) { + const webkitVideo = media as WebKitVideoElement; + if (webkitVideo.webkitPresentationMode === 'fullscreen') { + return true; + } + + const fullscreenElement = getFullscreenElement(); + if (fullscreenElement && (fullscreenElement === container || fullscreenElement === media)) { + return true; + } + + // `:fullscreen` matches the fullscreen element AND its ancestors (across + // shadow boundaries), so this covers cases where fullscreen was requested + // on a descendant — e.g. the inner `