mirror of
https://github.com/zoriya/v10.git
synced 2026-08-07 14:48:09 +00:00
feat(core): add presentation feature (#458)
This commit is contained in:
@@ -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';
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user