feat(core): add presentation feature (#458)

This commit is contained in:
rahim
2026-02-06 14:24:49 +11:00
committed by GitHub
parent 8f55d38133
commit d5e5cec6ab
12 changed files with 825 additions and 1 deletions
+54
View File
@@ -135,3 +135,57 @@ export interface BufferState {
*/
seekable: [number, number][];
}
export interface FullscreenState {
/**
* Whether fullscreen mode is currently active.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API
*/
fullscreen: boolean;
/**
* Whether fullscreen can be requested on this platform.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/fullscreenEnabled
*/
fullscreenAvailability: FeatureAvailability;
/**
* Enter fullscreen mode. Tries container first, falls back to media element.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullscreen
*/
requestFullscreen(): Promise<void>;
/**
* Exit fullscreen mode.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitFullscreen
*/
exitFullscreen(): Promise<void>;
}
export interface PictureInPictureState {
/**
* Whether picture-in-picture mode is currently active.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Picture-in-Picture_API
*/
pip: boolean;
/**
* Whether picture-in-picture can be requested on this platform.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/pictureInPictureEnabled
*/
pipAvailability: FeatureAvailability;
/**
* Enter picture-in-picture mode.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestPictureInPicture
*/
requestPiP(): Promise<void>;
/**
* Exit picture-in-picture mode.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitPictureInPicture
*/
exitPiP(): Promise<void>;
}
@@ -0,0 +1,118 @@
import { isFunction } from '@videojs/utils/predicate';
import type { WebKitDocument, WebKitFullscreenElement, WebKitVideoElement } from './types';
/** Check if the Fullscreen API is supported on this platform. */
export function isFullscreenSupported(): boolean {
const doc = document as WebKitDocument;
// Standard API or WebKit prefix
if (doc.fullscreenEnabled || doc.webkitFullscreenEnabled) {
return true;
}
// iOS Safari: check for webkitSupportsFullscreen on a test video
const video = document.createElement('video') as WebKitVideoElement;
return video.webkitSupportsFullscreen === true;
}
/** Get the current fullscreen element from the document. */
export function getFullscreenElement(): Element | null {
const doc = document as WebKitDocument;
return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
}
/**
* Check if a specific element (or its media) is currently in fullscreen.
*
* Uses `:fullscreen` pseudo-class which works across Shadow DOM boundaries.
*/
export function isElementFullscreen(container: HTMLElement | null, media: HTMLMediaElement): boolean {
const video = media as WebKitVideoElement;
// iOS Safari video-only fullscreen
if (video.webkitDisplayingFullscreen && video.webkitPresentationMode === 'fullscreen') {
return true;
}
const target = container ?? media;
// Direct match with fullscreen element
if (getFullscreenElement() === target) return true;
// Use :fullscreen pseudo-class (works in Shadow DOM)
try {
return target.matches(':fullscreen');
} catch {
return false;
}
}
/**
* Enter fullscreen mode.
*
* Tries container first (to show custom UI), falls back to media element
* for platforms that only support video fullscreen (iOS Safari).
*/
export async function enterFullscreen(container: HTMLElement | null, media: HTMLMediaElement): Promise<void> {
const video = media as WebKitVideoElement;
// Try container first (standard and WebKit APIs)
if (container) {
const el = container as WebKitFullscreenElement;
if (isFunction(el.requestFullscreen)) {
return el.requestFullscreen();
}
if (isFunction(el.webkitRequestFullscreen)) {
return el.webkitRequestFullscreen();
}
if (isFunction(el.webkitRequestFullScreen)) {
return el.webkitRequestFullScreen();
}
}
// Fall back to media element (iOS Safari)
if (isFunction(video.webkitEnterFullscreen)) {
video.webkitEnterFullscreen();
return;
}
// Last resort: try media element with standard API
if (isFunction(media.requestFullscreen)) {
return media.requestFullscreen();
}
throw new DOMException('Fullscreen not supported', 'NotSupportedError');
}
/** Exit fullscreen mode. */
export async function exitFullscreen(): Promise<void> {
const doc = document as WebKitDocument;
const video = getFullscreenElement() as WebKitVideoElement | null;
// Try standard API
if (isFunction(doc.exitFullscreen)) {
return doc.exitFullscreen();
}
// Try WebKit API
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)
}
@@ -0,0 +1,3 @@
export * from './fullscreen';
export * from './pip';
export * from './types';
+85
View File
@@ -0,0 +1,85 @@
import { isFunction } from '@videojs/utils/predicate';
import type { WebKitVideoElement } from './types';
/**
* Check if Picture-in-Picture is supported on this platform.
*
* Note: Safari PWAs don't support PiP even though the API exists.
*/
export function isPiPSupported(): boolean {
// Check standard PiP API
if (document.pictureInPictureEnabled) {
// Safari PWAs have the API but it doesn't work
const isSafari = /.*Version\/.*Safari\/.*/.test(navigator.userAgent);
const isPWA = typeof matchMedia === 'function' && matchMedia('(display-mode: standalone)').matches;
return !isSafari || !isPWA;
}
// Check iOS Safari WebKit presentation mode
const video = document.createElement('video') as WebKitVideoElement;
return isFunction(video.webkitSetPresentationMode);
}
/**
* Check if Picture-in-Picture is currently active for a media element.
*/
export function isPiPActive(media: HTMLMediaElement): boolean {
// Standard PiP API
if (document.pictureInPictureElement === media) {
return true;
}
// iOS Safari WebKit presentation mode
const video = media as WebKitVideoElement;
return video.webkitPresentationMode === 'picture-in-picture';
}
/**
* Enter Picture-in-Picture mode.
*
* Uses standard API where available, falls back to iOS Safari's
* WebKit presentation mode.
*/
export async function enterPiP(media: HTMLMediaElement): Promise<void> {
const video = media as HTMLVideoElement & WebKitVideoElement;
// Standard PiP API (only available on HTMLVideoElement)
if (isFunction(video.requestPictureInPicture)) {
await video.requestPictureInPicture();
return;
}
// iOS Safari WebKit presentation mode
if (isFunction(video.webkitSetPresentationMode)) {
video.webkitSetPresentationMode('picture-in-picture');
return;
}
throw new DOMException('Picture-in-Picture not supported', 'NotSupportedError');
}
/**
* Exit Picture-in-Picture mode.
*
* Uses standard API where available, falls back to iOS Safari's
* WebKit presentation mode.
*/
export async function exitPiP(media?: HTMLMediaElement): Promise<void> {
// Standard PiP API
if (document.pictureInPictureElement && isFunction(document.exitPictureInPicture)) {
await document.exitPictureInPicture();
return;
}
// iOS Safari WebKit presentation mode
if (media) {
const video = media as WebKitVideoElement;
if (video.webkitPresentationMode === 'picture-in-picture' && isFunction(video.webkitSetPresentationMode)) {
video.webkitSetPresentationMode('inline');
return;
}
}
// No-op if not in PiP (matches browser behavior)
}
@@ -0,0 +1,38 @@
/** WebKit presentation mode values for iOS Safari. */
export type WebKitPresentationMode = 'inline' | 'fullscreen' | 'picture-in-picture';
/** 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;
/** 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). */
webkitExitFullscreen?: () => void;
/** Set WebKit presentation mode (iOS Safari). */
webkitSetPresentationMode?: (mode: WebKitPresentationMode) => void;
}
/** Extended Element with WebKit fullscreen vendor API. */
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. */
export interface WebKitDocument extends Document {
/** Current fullscreen element (WebKit). */
webkitFullscreenElement?: Element | null;
/** Whether fullscreen is enabled (WebKit). */
webkitFullscreenEnabled?: boolean;
/** Exit fullscreen (WebKit). */
webkitExitFullscreen?: () => Promise<void>;
/** Exit fullscreen (older WebKit). */
webkitCancelFullScreen?: () => Promise<void>;
}
@@ -1,4 +1,6 @@
import { bufferFeature } from './buffer';
import { fullscreenFeature } from './fullscreen';
import { pipFeature } from './pip';
import { playbackFeature } from './playback';
import { sourceFeature } from './source';
import { timeFeature } from './time';
@@ -7,12 +9,22 @@ import { volumeFeature } from './volume';
// Short aliases
export {
bufferFeature as buffer,
fullscreenFeature as fullscreen,
pipFeature as pip,
playbackFeature as playback,
sourceFeature as source,
timeFeature as time,
volumeFeature as volume,
};
export const video = [playbackFeature, volumeFeature, timeFeature, sourceFeature, bufferFeature] as const;
export const video = [
playbackFeature,
volumeFeature,
timeFeature,
sourceFeature,
bufferFeature,
fullscreenFeature,
pipFeature,
] as const;
export const audio = [playbackFeature, volumeFeature, timeFeature, sourceFeature, bufferFeature] as const;
@@ -0,0 +1,58 @@
import { listen } from '@videojs/utils/dom';
import type { FullscreenState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
import {
enterFullscreen,
exitFullscreen,
isElementFullscreen,
isFullscreenSupported,
} from '../../presentation/fullscreen';
import { exitPiP, isPiPActive } from '../../presentation/pip';
import type { WebKitVideoElement } from '../../presentation/types';
export const fullscreenFeature = definePlayerFeature({
state: ({ target }): FullscreenState => ({
fullscreen: false,
fullscreenAvailability: 'unavailable',
async requestFullscreen() {
const { media, container } = target();
// Exit PiP first if active (browser behavior is inconsistent)
if (isPiPActive(media)) {
await exitPiP(media);
}
return enterFullscreen(container, media);
},
async exitFullscreen() {
return exitFullscreen();
},
}),
attach({ target, signal, set }) {
const { media, container } = target;
set({
fullscreenAvailability: isFullscreenSupported() ? 'available' : 'unsupported',
});
const sync = () =>
set({
fullscreen: isElementFullscreen(container, media),
});
sync();
listen(document, 'fullscreenchange', sync, { signal });
listen(document, 'webkitfullscreenchange', sync, { signal });
// iOS Safari presentation mode change (covers fullscreen)
const video = media as WebKitVideoElement;
if ('webkitPresentationMode' in video) {
listen(media, 'webkitpresentationmodechanged', sync, { signal });
}
},
});
@@ -1,5 +1,7 @@
export * from './buffer';
export * as features from './feature.parts';
export * from './fullscreen';
export * from './pip';
export * from './playback';
export * from './source';
export * from './time';
@@ -0,0 +1,54 @@
import { listen } from '@videojs/utils/dom';
import type { PictureInPictureState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
import { exitFullscreen, isElementFullscreen } from '../../presentation/fullscreen';
import { enterPiP, exitPiP, isPiPActive, isPiPSupported } from '../../presentation/pip';
import type { WebKitVideoElement } from '../../presentation/types';
export const pipFeature = definePlayerFeature({
state: ({ target }): PictureInPictureState => ({
pip: false,
pipAvailability: 'unavailable',
async requestPiP() {
const { media, container } = target();
// Exit fullscreen first if active
if (isElementFullscreen(container, media)) {
await exitFullscreen();
}
return enterPiP(media);
},
async exitPiP() {
const { media } = target();
return exitPiP(media);
},
}),
attach({ target, signal, set }) {
const { media } = target;
set({
pipAvailability: isPiPSupported() ? 'available' : 'unsupported',
});
const sync = () =>
set({
pip: isPiPActive(media),
});
sync();
listen(media, 'enterpictureinpicture', sync, { signal });
listen(media, 'leavepictureinpicture', sync, { signal });
// iOS Safari presentation mode change (covers PiP)
const video = media as WebKitVideoElement;
if ('webkitPresentationMode' in video) {
listen(media, 'webkitpresentationmodechanged', sync, { signal });
}
},
});
@@ -0,0 +1,223 @@
import { createStore } from '@videojs/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { fullscreenFeature } from '../fullscreen';
describe('fullscreenFeature', () => {
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 store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container });
expect(store.state.fullscreen).toBe(false);
});
it('detects fullscreen availability when supported', () => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container: null });
expect(store.state.fullscreenAvailability).toBe('available');
});
it('detects fullscreen unavailable when not supported', () => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: false,
writable: true,
configurable: true,
});
const video = createMockVideo();
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container: null });
expect(store.state.fullscreenAvailability).toBe('unsupported');
});
it('updates fullscreen on fullscreenchange event', () => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const container = document.createElement('div');
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container });
expect(store.state.fullscreen).toBe(false);
// Simulate entering fullscreen
Object.defineProperty(document, 'fullscreenElement', {
value: container,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('fullscreenchange'));
expect(store.state.fullscreen).toBe(true);
// Simulate exiting fullscreen
Object.defineProperty(document, 'fullscreenElement', {
value: null,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('fullscreenchange'));
expect(store.state.fullscreen).toBe(false);
});
it('stops listening when store is destroyed', () => {
Object.defineProperty(document, 'fullscreenEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const container = document.createElement('div');
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container });
store.destroy();
// Simulate entering fullscreen after destroy
Object.defineProperty(document, 'fullscreenElement', {
value: container,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('fullscreenchange'));
// State should not update after destroy
expect(store.state.fullscreen).toBe(false);
});
});
describe('actions', () => {
it('requestFullscreen() calls requestFullscreen on container', async () => {
const video = createMockVideo();
const container = document.createElement('div');
container.requestFullscreen = vi.fn().mockResolvedValue(undefined);
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container });
await store.requestFullscreen();
expect(container.requestFullscreen).toHaveBeenCalled();
});
it('requestFullscreen() falls back to media when no container', async () => {
const video = createMockVideo();
video.requestFullscreen = vi.fn().mockResolvedValue(undefined);
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container: null });
await store.requestFullscreen();
expect(video.requestFullscreen).toHaveBeenCalled();
});
it('exitFullscreen() calls document.exitFullscreen', async () => {
const originalExit = document.exitFullscreen;
document.exitFullscreen = vi.fn().mockResolvedValue(undefined);
const video = createMockVideo();
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container: null });
await store.exitFullscreen();
expect(document.exitFullscreen).toHaveBeenCalled();
document.exitFullscreen = originalExit;
});
});
describe('transitions', () => {
it('requestFullscreen() exits PiP first if active', async () => {
const originalExit = document.exitPictureInPicture;
document.exitPictureInPicture = vi.fn().mockResolvedValue(undefined);
const video = createMockVideo();
const container = document.createElement('div');
container.requestFullscreen = vi.fn().mockResolvedValue(undefined);
// Set PiP as active
Object.defineProperty(document, 'pictureInPictureElement', {
value: video,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container });
await store.requestFullscreen();
expect(document.exitPictureInPicture).toHaveBeenCalled();
expect(container.requestFullscreen).toHaveBeenCalled();
document.exitPictureInPicture = originalExit;
});
it('requestFullscreen() does not exit PiP if not active', async () => {
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 store = createStore<PlayerTarget>()(fullscreenFeature);
store.attach({ media: video, container });
await store.requestFullscreen();
expect(document.exitPictureInPicture).not.toHaveBeenCalled();
expect(container.requestFullscreen).toHaveBeenCalled();
document.exitPictureInPicture = originalExit;
});
});
});
function createMockVideo(): HTMLVideoElement {
return document.createElement('video');
}
@@ -0,0 +1,173 @@
import { createStore } from '@videojs/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { pipFeature } from '../pip';
describe('pipFeature', () => {
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 store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container: null });
expect(store.state.pip).toBe(false);
});
it('detects PiP availability when supported', () => {
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container: null });
expect(store.state.pipAvailability).toBe('available');
});
it('updates pip on PiP events', () => {
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: true,
writable: true,
configurable: true,
});
const video = createMockVideo();
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container: null });
expect(store.state.pip).toBe(false);
// Simulate entering PiP
Object.defineProperty(document, 'pictureInPictureElement', {
value: video,
writable: true,
configurable: true,
});
video.dispatchEvent(new Event('enterpictureinpicture'));
expect(store.state.pip).toBe(true);
// Simulate exiting PiP
Object.defineProperty(document, 'pictureInPictureElement', {
value: null,
writable: true,
configurable: true,
});
video.dispatchEvent(new Event('leavepictureinpicture'));
expect(store.state.pip).toBe(false);
});
});
describe('actions', () => {
it('requestPiP() calls requestPictureInPicture on video', async () => {
const video = createMockVideo();
video.requestPictureInPicture = vi.fn().mockResolvedValue({});
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container: null });
await store.requestPiP();
expect(video.requestPictureInPicture).toHaveBeenCalled();
});
it('exitPiP() calls document.exitPictureInPicture', async () => {
const originalExit = document.exitPictureInPicture;
document.exitPictureInPicture = vi.fn().mockResolvedValue(undefined);
const video = createMockVideo();
// Set the video as the current PiP element
Object.defineProperty(document, 'pictureInPictureElement', {
value: video,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container: null });
await store.exitPiP();
expect(document.exitPictureInPicture).toHaveBeenCalled();
document.exitPictureInPicture = originalExit;
});
});
describe('transitions', () => {
it('requestPiP() 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');
// Set fullscreen as active
Object.defineProperty(document, 'fullscreenElement', {
value: container,
writable: true,
configurable: true,
});
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container });
await store.requestPiP();
expect(document.exitFullscreen).toHaveBeenCalled();
expect(video.requestPictureInPicture).toHaveBeenCalled();
document.exitFullscreen = originalExit;
});
it('requestPiP() does not exit fullscreen if not active', async () => {
const originalExit = document.exitFullscreen;
document.exitFullscreen = vi.fn().mockResolvedValue(undefined);
const video = createMockVideo();
video.requestPictureInPicture = vi.fn().mockResolvedValue({});
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container: null });
await store.requestPiP();
expect(document.exitFullscreen).not.toHaveBeenCalled();
expect(video.requestPictureInPicture).toHaveBeenCalled();
document.exitFullscreen = originalExit;
});
});
});
function createMockVideo(): HTMLVideoElement {
return document.createElement('video');
}
+4
View File
@@ -1,12 +1,16 @@
import { createSelector } from '@videojs/store';
import { bufferFeature } from './features/buffer';
import { fullscreenFeature } from './features/fullscreen';
import { pipFeature } from './features/pip';
import { playbackFeature } from './features/playback';
import { sourceFeature } from './features/source';
import { timeFeature } from './features/time';
import { volumeFeature } from './features/volume';
export const selectBuffer = createSelector(bufferFeature);
export const selectFullscreen = createSelector(fullscreenFeature);
export const selectPiP = createSelector(pipFeature);
export const selectPlayback = createSelector(playbackFeature);
export const selectSource = createSelector(sourceFeature);
export const selectTime = createSelector(timeFeature);