diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 4cf0798a..4ad75928 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -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; + /** + * Exit fullscreen mode. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitFullscreen + */ + exitFullscreen(): Promise; +} + +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; + /** + * Exit picture-in-picture mode. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitPictureInPicture + */ + exitPiP(): Promise; +} diff --git a/packages/core/src/dom/presentation/fullscreen.ts b/packages/core/src/dom/presentation/fullscreen.ts new file mode 100644 index 00000000..e583cb98 --- /dev/null +++ b/packages/core/src/dom/presentation/fullscreen.ts @@ -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 { + 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 { + 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) +} diff --git a/packages/core/src/dom/presentation/index.ts b/packages/core/src/dom/presentation/index.ts new file mode 100644 index 00000000..f8996383 --- /dev/null +++ b/packages/core/src/dom/presentation/index.ts @@ -0,0 +1,3 @@ +export * from './fullscreen'; +export * from './pip'; +export * from './types'; diff --git a/packages/core/src/dom/presentation/pip.ts b/packages/core/src/dom/presentation/pip.ts new file mode 100644 index 00000000..b39205b6 --- /dev/null +++ b/packages/core/src/dom/presentation/pip.ts @@ -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 { + 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 { + // 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) +} diff --git a/packages/core/src/dom/presentation/types.ts b/packages/core/src/dom/presentation/types.ts new file mode 100644 index 00000000..845c7fc6 --- /dev/null +++ b/packages/core/src/dom/presentation/types.ts @@ -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; + /** Request fullscreen using WebKit API (older Safari). */ + webkitRequestFullScreen?: () => Promise; +} + +/** 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; + /** Exit fullscreen (older WebKit). */ + webkitCancelFullScreen?: () => Promise; +} diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index a8d8a164..d54ad5cd 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -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; diff --git a/packages/core/src/dom/store/features/fullscreen.ts b/packages/core/src/dom/store/features/fullscreen.ts new file mode 100644 index 00000000..e3e17189 --- /dev/null +++ b/packages/core/src/dom/store/features/fullscreen.ts @@ -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 }); + } + }, +}); diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 5689a05c..45552105 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -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'; diff --git a/packages/core/src/dom/store/features/pip.ts b/packages/core/src/dom/store/features/pip.ts new file mode 100644 index 00000000..916d95e9 --- /dev/null +++ b/packages/core/src/dom/store/features/pip.ts @@ -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 }); + } + }, +}); diff --git a/packages/core/src/dom/store/features/tests/fullscreen.test.ts b/packages/core/src/dom/store/features/tests/fullscreen.test.ts new file mode 100644 index 00000000..b98f1a6f --- /dev/null +++ b/packages/core/src/dom/store/features/tests/fullscreen.test.ts @@ -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()(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()(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()(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()(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()(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()(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()(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()(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()(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()(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'); +} diff --git a/packages/core/src/dom/store/features/tests/pip.test.ts b/packages/core/src/dom/store/features/tests/pip.test.ts new file mode 100644 index 00000000..4e9c7a32 --- /dev/null +++ b/packages/core/src/dom/store/features/tests/pip.test.ts @@ -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()(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()(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()(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()(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()(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()(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()(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'); +} diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index d9ac1168..7ba6c61f 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -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);