mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(core)!: unify fullscreen and pip on media capabilities (#1469)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user