refactor(core)!: unify fullscreen and pip on media capabilities (#1469)

This commit is contained in:
Wesley Luyten
2026-04-27 11:59:14 -07:00
committed by GitHub
parent b830baee6b
commit bb72a84dc1
11 changed files with 808 additions and 181 deletions
+5 -1
View File
@@ -174,11 +174,15 @@ export interface MediaTextTrackCapability {
}
export interface MediaFullscreenCapability {
requestFullscreen(): Promise<void>;
readonly isFullscreen: boolean;
requestFullscreen(): Promise<unknown>;
exitFullscreen(): Promise<unknown>;
}
export interface MediaPictureInPictureCapability {
readonly isPictureInPicture: boolean;
requestPictureInPicture(): Promise<unknown>;
exitPictureInPicture(): Promise<unknown>;
}
export interface MediaRemotePlaybackCapability {
@@ -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);
});
});
});
+36 -19
View File
@@ -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<HTMLVideoElement, VideoEvents> implements Video {
@@ -11,31 +12,47 @@ export class HTMLVideoElementHost extends HTMLMediaElementHost<HTMLVideoElement,
if (this.target) this.target.poster = value;
}
get webkitDisplayingFullscreen() {
return (this.target as unknown as WebKitVideoElement | null)?.webkitDisplayingFullscreen ?? false;
}
get webkitPresentationMode() {
return (this.target as unknown as WebKitVideoElement | null)?.webkitPresentationMode ?? 'inline';
return (this.target as WebKitVideoElement | null)?.webkitPresentationMode;
}
requestPictureInPicture() {
return this.target?.requestPictureInPicture() ?? Promise.reject();
get webkitSetPresentationMode(): ((mode: WebKitPresentationMode) => 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();
}
}
@@ -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<void> {
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 `<video>` via native controls — rather
// than the container itself.
if (matchesFullscreen(container) || matchesFullscreen(media)) {
return true;
}
// isFullscreen is a non-standard property that is set by the video host
// and checks internally if the video host target is the fullscreen element.
const video = media as unknown as MediaFullscreenCapability;
return video.isFullscreen ?? false;
}
export async function requestFullscreen(container: HTMLElement | null, media: EventTarget) {
const doc = document as WebKitDocument;
if (container && (doc.fullscreenEnabled || doc.webkitFullscreenEnabled)) {
@@ -56,30 +66,25 @@ export async function requestFullscreen(container: HTMLElement | null, media: Ev
}
}
if (media instanceof HTMLMediaElement) {
const video = media as WebKitVideoElement;
if (isFunction(video.webkitEnterFullscreen)) {
video.webkitEnterFullscreen();
return;
}
if (isFunction(media.requestFullscreen)) {
return media.requestFullscreen();
}
const webkitVideo = media as WebKitVideoElement;
if (isFunction(webkitVideo.webkitSetPresentationMode)) {
webkitVideo.webkitSetPresentationMode('fullscreen');
return;
}
throw new DOMException('Fullscreen not supported', 'NotSupportedError');
const video = media as unknown as MediaFullscreenCapability;
if (isFunction(video.requestFullscreen)) {
return video.requestFullscreen() as Promise<void>;
}
}
export async function exitFullscreen(media?: EventTarget): Promise<void> {
export async function exitFullscreen(media: EventTarget) {
const doc = document as WebKitDocument;
if (media instanceof HTMLMediaElement) {
const video = media as WebKitVideoElement;
if (isFunction(video.webkitExitFullscreen) && video.webkitDisplayingFullscreen) {
video.webkitExitFullscreen();
return;
}
const webkitVideo = media as WebKitVideoElement;
if (webkitVideo.webkitPresentationMode === 'fullscreen' && isFunction(webkitVideo.webkitSetPresentationMode)) {
webkitVideo.webkitSetPresentationMode('inline');
return;
}
if (isFunction(doc.exitFullscreen)) {
@@ -89,4 +94,9 @@ export async function exitFullscreen(media?: EventTarget): Promise<void> {
if (isFunction(doc.webkitExitFullscreen)) {
return doc.webkitExitFullscreen();
}
const video = media as unknown as MediaFullscreenCapability;
if (isFunction(video.exitFullscreen)) {
return video.exitFullscreen() as Promise<void>;
}
}
+30 -40
View File
@@ -1,13 +1,8 @@
import { isFunction } from '@videojs/utils/predicate';
import type { MediaPictureInPictureCapability } from '../../core/media/types';
import type { WebKitVideoElement } from './types';
function resolveMediaTarget(media: EventTarget): EventTarget {
const target = (media as EventTarget & { target?: unknown }).target;
return target instanceof HTMLMediaElement ? target : media;
}
export function isPictureInPictureEnabled(): boolean {
export function isPictureInPictureEnabled() {
if (document.pictureInPictureEnabled) {
const isSafari = /.*Version\/.*Safari\/.*/.test(navigator.userAgent);
const isPWA = typeof matchMedia === 'function' && matchMedia('(display-mode: standalone)').matches;
@@ -18,56 +13,51 @@ export function isPictureInPictureEnabled(): boolean {
return isFunction(video.webkitSetPresentationMode);
}
export function isPictureInPictureElement(media: EventTarget): boolean {
const target = resolveMediaTarget(media);
if (document.pictureInPictureElement === target) {
export function isPictureInPicture(media: EventTarget) {
const webkitVideo = media as WebKitVideoElement;
if (webkitVideo.webkitPresentationMode === 'picture-in-picture') {
return true;
}
if (target instanceof HTMLVideoElement) {
const video = target as WebKitVideoElement;
return video.webkitPresentationMode === 'picture-in-picture';
if (document.pictureInPictureElement === media) {
return true;
}
return false;
// isPictureInPicture is a non-standard property that is set by the video host
// and checks internally if the video host target is the picture-in-picture element.
const video = media as unknown as MediaPictureInPictureCapability;
return video.isPictureInPicture ?? false;
}
export async function requestPictureInPicture(media: EventTarget): Promise<void> {
const target = resolveMediaTarget(media);
if (!(target instanceof HTMLVideoElement)) {
throw new DOMException('Picture-in-Picture not supported', 'NotSupportedError');
}
const video = target as HTMLVideoElement & WebKitVideoElement;
if (isFunction(video.webkitSetPresentationMode)) {
video.webkitSetPresentationMode('picture-in-picture');
export async function requestPictureInPicture(media: EventTarget) {
const webkitVideo = media as WebKitVideoElement;
if (isFunction(webkitVideo.webkitSetPresentationMode)) {
webkitVideo.webkitSetPresentationMode('picture-in-picture');
return;
}
const video = media as unknown as MediaPictureInPictureCapability;
if (isFunction(video.requestPictureInPicture)) {
await video.requestPictureInPicture();
return;
return video.requestPictureInPicture() as Promise<void>;
}
throw new DOMException('Picture-in-Picture not supported', 'NotSupportedError');
}
export async function exitPictureInPicture(media?: EventTarget): Promise<void> {
if (media) {
const target = resolveMediaTarget(media);
if (target instanceof HTMLVideoElement) {
const video = target as WebKitVideoElement;
if (isFunction(video.webkitSetPresentationMode) && video.webkitPresentationMode === 'picture-in-picture') {
video.webkitSetPresentationMode('inline');
return;
}
}
export async function exitPictureInPicture(media: EventTarget) {
const webkitVideo = media as WebKitVideoElement;
if (
webkitVideo.webkitPresentationMode === 'picture-in-picture' &&
isFunction(webkitVideo.webkitSetPresentationMode)
) {
webkitVideo.webkitSetPresentationMode('inline');
return;
}
if (isFunction(document.exitPictureInPicture)) {
return document.exitPictureInPicture();
}
const video = media as unknown as MediaPictureInPictureCapability;
if (isFunction(video.exitPictureInPicture)) {
return video.exitPictureInPicture() as Promise<void>;
}
}
@@ -3,14 +3,8 @@ export type WebKitPresentationMode = 'inline' | 'fullscreen' | 'picture-in-pictu
/** Extended HTMLVideoElement with WebKit vendor APIs. */
export interface WebKitVideoElement extends HTMLVideoElement {
/** Whether the video is displaying in fullscreen (iOS Safari). */
webkitDisplayingFullscreen?: boolean;
/** Current WebKit presentation mode (iOS Safari). */
webkitPresentationMode?: WebKitPresentationMode;
/** Enter fullscreen using WebKit API (iOS Safari). */
webkitEnterFullscreen?: () => void;
/** Exit fullscreen using WebKit API (iOS Safari). */
webkitExitFullscreen?: () => void;
/** Set WebKit presentation mode (iOS Safari). */
webkitSetPresentationMode?: (mode: WebKitPresentationMode) => void;
}
@@ -2,13 +2,8 @@ import { listen } from '@videojs/utils/dom';
import type { MediaFullscreenState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
import {
exitFullscreen,
isFullscreenElement,
isFullscreenEnabled,
requestFullscreen,
} from '../../presentation/fullscreen';
import { exitPictureInPicture, isPictureInPictureElement } from '../../presentation/pip';
import { exitFullscreen, isFullscreen, isFullscreenEnabled, requestFullscreen } from '../../presentation/fullscreen';
import { exitPictureInPicture, isPictureInPicture } from '../../presentation/pip';
import type { WebKitVideoElement } from '../../presentation/types';
export const fullscreenFeature = definePlayerFeature({
@@ -21,7 +16,7 @@ export const fullscreenFeature = definePlayerFeature({
const { media, container } = target();
// Exit PiP first if active (browser behavior is inconsistent)
if (isPictureInPictureElement(media)) {
if (isPictureInPicture(media)) {
await exitPictureInPicture(media);
}
@@ -36,11 +31,11 @@ export const fullscreenFeature = definePlayerFeature({
async toggleFullscreen() {
const { media, container } = target();
if (isFullscreenElement(container, media)) {
if (isFullscreen(container, media)) {
return exitFullscreen(media);
}
if (isPictureInPictureElement(media)) {
if (isPictureInPicture(media)) {
await exitPictureInPicture(media);
}
@@ -57,7 +52,7 @@ export const fullscreenFeature = definePlayerFeature({
const sync = () =>
set({
fullscreen: isFullscreenElement(container, media),
fullscreen: isFullscreen(container, media),
});
sync();
+8 -8
View File
@@ -2,10 +2,10 @@ import { listen } from '@videojs/utils/dom';
import type { MediaPictureInPictureState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
import { exitFullscreen, isFullscreenElement } from '../../presentation/fullscreen';
import { exitFullscreen, isFullscreen } from '../../presentation/fullscreen';
import {
exitPictureInPicture,
isPictureInPictureElement,
isPictureInPicture,
isPictureInPictureEnabled,
requestPictureInPicture,
} from '../../presentation/pip';
@@ -21,8 +21,8 @@ export const pipFeature = definePlayerFeature({
const { media, container } = target();
// Exit fullscreen first if active
if (isFullscreenElement(container, media)) {
await exitFullscreen();
if (isFullscreen(container, media)) {
await exitFullscreen(media);
}
return requestPictureInPicture(media);
@@ -36,12 +36,12 @@ export const pipFeature = definePlayerFeature({
async togglePictureInPicture() {
const { media, container } = target();
if (isPictureInPictureElement(media)) {
if (isPictureInPicture(media)) {
return exitPictureInPicture(media);
}
if (isFullscreenElement(container, media)) {
await exitFullscreen();
if (isFullscreen(container, media)) {
await exitFullscreen(media);
}
return requestPictureInPicture(media);
@@ -57,7 +57,7 @@ export const pipFeature = definePlayerFeature({
const sync = () =>
set({
pip: isPictureInPictureElement(media),
pip: isPictureInPicture(media),
});
sync();
@@ -3,7 +3,7 @@ import { listen } from '@videojs/utils/dom';
import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
import { isMediaRemotePlaybackCapable } from '../../media/predicate';
import { exitFullscreen, isFullscreenElement } from '../../presentation/fullscreen';
import { exitFullscreen, isFullscreen } from '../../presentation/fullscreen';
import { isRemotePlaybackConnected, requestRemotePlayback } from '../../presentation/remote-playback';
export const remotePlaybackFeature = definePlayerFeature({
@@ -19,8 +19,8 @@ export const remotePlaybackFeature = definePlayerFeature({
return requestRemotePlayback(media);
}
if (isFullscreenElement(container, media)) {
await exitFullscreen();
if (isFullscreen(container, media)) {
await exitFullscreen(media);
}
return requestRemotePlayback(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 { HTMLVideoElementHost } from '../../../media/video-host';
import type { WebKitVideoElement } from '../../../presentation/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { fullscreenFeature } from '../fullscreen';
@@ -64,17 +65,17 @@ describe('fullscreenFeature', () => {
expect(store.state.fullscreenAvailability).toBe('unsupported');
});
it('detects fullscreen availability via webkitEnterFullscreen (iOS Safari)', () => {
it('detects fullscreen availability via webkitSetPresentationMode (iOS Safari)', () => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: false,
writable: true,
configurable: true,
});
// Simulate iOS Safari: webkitEnterFullscreen on video prototype
// Simulate iOS Safari: webkitSetPresentationMode on video prototype
const proto = HTMLVideoElement.prototype as WebKitVideoElement;
const original = proto.webkitEnterFullscreen;
proto.webkitEnterFullscreen = () => {};
const original = proto.webkitSetPresentationMode;
proto.webkitSetPresentationMode = () => {};
const video = createMockVideo();
const store = createStore<PlayerTarget>()(fullscreenFeature);
@@ -83,9 +84,9 @@ describe('fullscreenFeature', () => {
expect(store.state.fullscreenAvailability).toBe('available');
if (original) {
proto.webkitEnterFullscreen = original;
proto.webkitSetPresentationMode = original;
} else {
delete proto.webkitEnterFullscreen;
delete proto.webkitSetPresentationMode;
}
});
@@ -98,7 +99,6 @@ describe('fullscreenFeature', () => {
const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement;
video.webkitPresentationMode = 'inline';
video.webkitDisplayingFullscreen = false;
const container = document.createElement('div');
const store = createStore<PlayerTarget>()(fullscreenFeature);
@@ -108,14 +108,12 @@ describe('fullscreenFeature', () => {
// 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);
@@ -157,6 +155,77 @@ describe('fullscreenFeature', () => {
expect(store.state.fullscreen).toBe(false);
});
it('detects fullscreen via :fullscreen pseudo-class when container is in a shadow tree', () => {
// When `requestFullscreen()` is called on an element inside a shadow
// tree, `document.fullscreenElement` returns the shadow host — not the
// actual fullscreen element. The `:fullscreen` pseudo-class matches the
// real fullscreen element across shadow boundaries.
Object.defineProperty(document, 'fullscreenEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const shadowHost = document.createElement('div');
const container = document.createElement('div');
const matchesSpy = vi.spyOn(container, 'matches').mockImplementation((selector) => selector === ':fullscreen');
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container });
Object.defineProperty(document, 'fullscreenElement', {
value: shadowHost,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('fullscreenchange'));
expect(store.state.fullscreen).toBe(true);
matchesSpy.mockReturnValue(false);
Object.defineProperty(document, 'fullscreenElement', {
value: null,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('fullscreenchange'));
expect(store.state.fullscreen).toBe(false);
matchesSpy.mockRestore();
});
it('detects fullscreen via :fullscreen pseudo-class on the media element', () => {
// When the inner `<video>` of a custom media element is fullscreened
// directly (e.g. via native controls), `document.fullscreenElement`
// points to a different element, but `:fullscreen` matches the media
// element because the fullscreen flag propagates to ancestors across
// shadow boundaries.
Object.defineProperty(document, 'fullscreenEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const container = document.createElement('div');
const unrelated = document.createElement('div');
const matchesSpy = vi.spyOn(video, 'matches').mockImplementation((selector) => selector === ':fullscreen');
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container });
Object.defineProperty(document, 'fullscreenElement', {
value: unrelated,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('fullscreenchange'));
expect(store.state.fullscreen).toBe(true);
matchesSpy.mockRestore();
});
it('stops listening when store is destroyed', () => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: true,
@@ -233,7 +302,7 @@ describe('fullscreenFeature', () => {
document.exitFullscreen = originalExit;
});
it('requestFullscreen() uses webkitEnterFullscreen when element fullscreen is unsupported (iOS Safari)', async () => {
it('requestFullscreen() uses webkitSetPresentationMode when element fullscreen is unsupported (iOS Safari)', async () => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: false,
writable: true,
@@ -241,7 +310,7 @@ describe('fullscreenFeature', () => {
});
const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement;
video.webkitEnterFullscreen = vi.fn();
video.webkitSetPresentationMode = vi.fn();
const container = document.createElement('div');
const store = createStore<PlayerTarget>()(fullscreenFeature);
@@ -249,23 +318,23 @@ describe('fullscreenFeature', () => {
await store.requestFullscreen();
expect(video.webkitEnterFullscreen).toHaveBeenCalled();
expect(video.webkitSetPresentationMode).toHaveBeenCalledWith('fullscreen');
});
it('exitFullscreen() uses webkitExitFullscreen first when video is in WebKit fullscreen (iOS Safari)', async () => {
it('exitFullscreen() uses webkitSetPresentationMode first when available (iOS Safari)', async () => {
const originalExit = document.exitFullscreen;
document.exitFullscreen = vi.fn();
const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement;
video.webkitExitFullscreen = vi.fn();
video.webkitDisplayingFullscreen = true;
video.webkitPresentationMode = 'fullscreen';
video.webkitSetPresentationMode = vi.fn();
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container: null });
await store.exitFullscreen();
expect(video.webkitExitFullscreen).toHaveBeenCalled();
expect(video.webkitSetPresentationMode).toHaveBeenCalledWith('inline');
expect(document.exitFullscreen).not.toHaveBeenCalled();
document.exitFullscreen = originalExit;
@@ -331,3 +400,230 @@ describe('fullscreenFeature', () => {
});
});
});
describe('fullscreenFeature with HTMLVideoElementHost', () => {
let originalFullscreenEnabled: boolean | undefined;
beforeEach(() => {
originalFullscreenEnabled = document.fullscreenEnabled;
});
afterEach(() => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: originalFullscreenEnabled,
writable: true,
configurable: true,
});
Object.defineProperty(document, 'fullscreenElement', {
value: null,
writable: true,
configurable: true,
});
});
describe('attach', () => {
it('syncs initial state on attach', () => {
const video = createMockVideo();
const container = document.createElement('div');
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: host, container });
expect(store.state.fullscreen).toBe(false);
});
it('reflects host.isFullscreen when document.fullscreenElement is the underlying video', () => {
const video = createMockVideo();
const host = new HTMLVideoElementHost();
host.attach(video);
Object.defineProperty(document, 'fullscreenElement', {
value: video,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: host, container: null });
expect(store.state.fullscreen).toBe(true);
});
it('updates fullscreen on fullscreenchange when container matches', () => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const container = document.createElement('div');
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: host, container });
expect(store.state.fullscreen).toBe(false);
Object.defineProperty(document, 'fullscreenElement', {
value: container,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('fullscreenchange'));
expect(store.state.fullscreen).toBe(true);
Object.defineProperty(document, 'fullscreenElement', {
value: null,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('fullscreenchange'));
expect(store.state.fullscreen).toBe(false);
});
it('syncs fullscreen on webkitpresentationmodechanged forwarded from target (iOS Safari)', () => {
const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement;
video.webkitPresentationMode = 'inline';
const container = document.createElement('div');
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: host, container });
expect(store.state.fullscreen).toBe(false);
video.webkitPresentationMode = 'fullscreen';
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
expect(store.state.fullscreen).toBe(true);
video.webkitPresentationMode = 'inline';
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
expect(store.state.fullscreen).toBe(false);
});
});
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);
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: host, container });
await store.requestFullscreen();
expect(container.requestFullscreen).toHaveBeenCalled();
});
it('requestFullscreen() falls back to host.requestFullscreen when no container', async () => {
const video = createMockVideo();
video.requestFullscreen = vi.fn().mockResolvedValue(undefined);
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: host, container: null });
await store.requestFullscreen();
expect(video.requestFullscreen).toHaveBeenCalled();
});
it('requestFullscreen() prefers webkitSetPresentationMode on the underlying video (iOS Safari)', async () => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: false,
writable: true,
configurable: true,
});
const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement;
video.webkitSetPresentationMode = vi.fn();
const container = document.createElement('div');
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: host, container });
await store.requestFullscreen();
expect(video.webkitSetPresentationMode).toHaveBeenCalledWith('fullscreen');
});
it('exitFullscreen() calls document.exitFullscreen', async () => {
const originalExit = document.exitFullscreen;
document.exitFullscreen = vi.fn().mockResolvedValue(undefined);
const video = createMockVideo();
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: host, container: null });
await store.exitFullscreen();
expect(document.exitFullscreen).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);
const video = createMockVideo();
const container = document.createElement('div');
container.requestFullscreen = vi.fn().mockResolvedValue(undefined);
const host = new HTMLVideoElementHost();
host.attach(video);
Object.defineProperty(document, 'pictureInPictureElement', {
value: video,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: host, container });
await store.requestFullscreen();
expect(document.exitPictureInPicture).toHaveBeenCalled();
expect(container.requestFullscreen).toHaveBeenCalled();
document.exitPictureInPicture = originalExit;
Object.defineProperty(document, 'pictureInPictureElement', {
value: null,
writable: true,
configurable: true,
});
});
});
});
@@ -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 { HTMLVideoElementHost } from '../../../media/video-host';
import type { WebKitVideoElement } from '../../../presentation/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { pipFeature } from '../pip';
@@ -105,39 +106,6 @@ 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<PlayerTarget>()(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', () => {
@@ -259,3 +227,202 @@ describe('pipFeature', () => {
});
});
});
describe('pipFeature with HTMLVideoElementHost', () => {
let originalPictureInPictureEnabled: boolean | undefined;
beforeEach(() => {
originalPictureInPictureEnabled = document.pictureInPictureEnabled;
});
afterEach(() => {
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: originalPictureInPictureEnabled,
writable: true,
configurable: true,
});
Object.defineProperty(document, 'pictureInPictureElement', {
value: null,
writable: true,
configurable: true,
});
});
describe('attach', () => {
it('syncs initial state on attach', () => {
const video = createMockVideo();
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: host, container: null });
expect(store.state.pip).toBe(false);
});
it('reflects host.isPictureInPicture when document PiP element is the underlying video', () => {
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const host = new HTMLVideoElementHost();
host.attach(video);
Object.defineProperty(document, 'pictureInPictureElement', {
value: video,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: host, container: null });
expect(store.state.pip).toBe(true);
});
it('updates pip on PiP events forwarded from target', () => {
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: host, container: null });
expect(store.state.pip).toBe(false);
Object.defineProperty(document, 'pictureInPictureElement', {
value: video,
writable: true,
configurable: true,
});
video.dispatchEvent(new Event('enterpictureinpicture'));
expect(store.state.pip).toBe(true);
Object.defineProperty(document, 'pictureInPictureElement', {
value: null,
writable: true,
configurable: true,
});
video.dispatchEvent(new Event('leavepictureinpicture'));
expect(store.state.pip).toBe(false);
});
it('syncs pip on webkitpresentationmodechanged forwarded from target (iOS Safari)', () => {
const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement;
video.webkitPresentationMode = 'inline';
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: host, container: null });
expect(store.state.pip).toBe(false);
video.webkitPresentationMode = 'picture-in-picture';
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
expect(store.state.pip).toBe(true);
video.webkitPresentationMode = 'inline';
video.dispatchEvent(new Event('webkitpresentationmodechanged'));
expect(store.state.pip).toBe(false);
});
});
describe('actions', () => {
it('requestPictureInPicture() delegates to underlying video', async () => {
const video = createMockVideo();
video.requestPictureInPicture = vi.fn().mockResolvedValue({});
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: host, container: null });
await store.requestPictureInPicture();
expect(video.requestPictureInPicture).toHaveBeenCalled();
});
it('requestPictureInPicture() prefers webkitSetPresentationMode on the underlying video (iOS Safari)', async () => {
const video = createMockVideo() as HTMLVideoElement & WebKitVideoElement;
video.requestPictureInPicture = vi.fn().mockResolvedValue({});
video.webkitSetPresentationMode = vi.fn();
const host = new HTMLVideoElementHost();
host.attach(video);
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: host, container: null });
await store.requestPictureInPicture();
expect(video.webkitSetPresentationMode).toHaveBeenCalledWith('picture-in-picture');
expect(video.requestPictureInPicture).not.toHaveBeenCalled();
});
it('exitPictureInPicture() calls document.exitPictureInPicture when underlying video is the PiP element', async () => {
const originalExit = document.exitPictureInPicture;
document.exitPictureInPicture = vi.fn().mockResolvedValue(undefined);
const video = createMockVideo();
const host = new HTMLVideoElementHost();
host.attach(video);
Object.defineProperty(document, 'pictureInPictureElement', {
value: video,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: host, container: null });
await store.exitPictureInPicture();
expect(document.exitPictureInPicture).toHaveBeenCalled();
document.exitPictureInPicture = originalExit;
});
});
describe('transitions', () => {
it('requestPictureInPicture() exits fullscreen first if active', async () => {
const originalExit = document.exitFullscreen;
document.exitFullscreen = vi.fn().mockResolvedValue(undefined);
const video = createMockVideo();
video.requestPictureInPicture = vi.fn().mockResolvedValue({});
const container = document.createElement('div');
const host = new HTMLVideoElementHost();
host.attach(video);
Object.defineProperty(document, 'fullscreenElement', {
value: container,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: host, container });
await store.requestPictureInPicture();
expect(document.exitFullscreen).toHaveBeenCalled();
expect(video.requestPictureInPicture).toHaveBeenCalled();
document.exitFullscreen = originalExit;
});
});
});