feat: small state and naming fixes (#719)

This commit is contained in:
Wesley Luyten
2026-03-04 16:05:20 -06:00
committed by GitHub
parent 070d094444
commit 5c422452e4
25 changed files with 94 additions and 88 deletions
+4 -4
View File
@@ -62,13 +62,13 @@ export interface MediaVolumeState {
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/volume
*/
changeVolume(volume: number): number;
setVolume(volume: number): number;
/**
* Toggle mute state. Returns the new muted value.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/muted
*/
toggleMute(): boolean;
toggleMuted(): boolean;
}
export interface MediaTimeState {
@@ -224,11 +224,11 @@ export interface MediaPictureInPictureState {
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestPictureInPicture
*/
requestPiP(): Promise<void>;
requestPictureInPicture(): Promise<void>;
/**
* Exit picture-in-picture mode.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitPictureInPicture
*/
exitPiP(): Promise<void>;
exitPictureInPicture(): Promise<void>;
}
@@ -75,7 +75,7 @@ export class MuteButtonCore {
toggle(media: MediaVolumeState): void {
if (this.#props.disabled) return;
media.toggleMute();
media.toggleMuted();
}
}
@@ -9,8 +9,8 @@ function createMediaState(overrides: Partial<MediaVolumeState> = {}): MediaVolum
volume: 1,
muted: false,
volumeAvailability: 'available',
changeVolume: vi.fn((v: number) => v),
toggleMute: vi.fn(() => false),
setVolume: vi.fn((v: number) => v),
toggleMuted: vi.fn(() => false),
...overrides,
};
}
@@ -108,18 +108,18 @@ describe('MuteButtonCore', () => {
});
describe('toggle', () => {
it('calls toggleMute', () => {
it('calls toggleMuted', () => {
const core = new MuteButtonCore();
const media = createMediaState();
core.toggle(media);
expect(media.toggleMute).toHaveBeenCalled();
expect(media.toggleMuted).toHaveBeenCalled();
});
it('does nothing when disabled', () => {
const core = new MuteButtonCore({ disabled: true });
const media = createMediaState();
core.toggle(media);
expect(media.toggleMute).not.toHaveBeenCalled();
expect(media.toggleMuted).not.toHaveBeenCalled();
});
});
});
@@ -71,9 +71,9 @@ export class PiPButtonCore {
try {
if (media.pip) {
await media.exitPiP();
await media.exitPictureInPicture();
} else {
await media.requestPiP();
await media.requestPictureInPicture();
}
} catch {
// PiP requests can fail (user gesture required, permissions, etc.)
@@ -8,8 +8,8 @@ function createMediaState(overrides: Partial<MediaPictureInPictureState> = {}):
return {
pip: false,
pipAvailability: 'available',
requestPiP: vi.fn(async () => {}),
exitPiP: vi.fn(async () => {}),
requestPictureInPicture: vi.fn(async () => {}),
exitPictureInPicture: vi.fn(async () => {}),
...overrides,
};
}
@@ -82,38 +82,38 @@ describe('PiPButtonCore', () => {
});
describe('toggle', () => {
it('calls requestPiP when not in PiP', async () => {
it('calls requestPictureInPicture when not in PiP', async () => {
const core = new PiPButtonCore();
const media = createMediaState({ pip: false });
await core.toggle(media);
expect(media.requestPiP).toHaveBeenCalled();
expect(media.requestPictureInPicture).toHaveBeenCalled();
});
it('calls exitPiP when in PiP', async () => {
it('calls exitPictureInPicture when in PiP', async () => {
const core = new PiPButtonCore();
const media = createMediaState({ pip: true });
await core.toggle(media);
expect(media.exitPiP).toHaveBeenCalled();
expect(media.exitPictureInPicture).toHaveBeenCalled();
});
it('does nothing when disabled', async () => {
const core = new PiPButtonCore({ disabled: true });
const media = createMediaState();
await core.toggle(media);
expect(media.requestPiP).not.toHaveBeenCalled();
expect(media.requestPictureInPicture).not.toHaveBeenCalled();
});
it('does nothing when unsupported', async () => {
const core = new PiPButtonCore();
const media = createMediaState({ pipAvailability: 'unsupported' });
await core.toggle(media);
expect(media.requestPiP).not.toHaveBeenCalled();
expect(media.requestPictureInPicture).not.toHaveBeenCalled();
});
it('catches PiP errors silently', async () => {
const core = new PiPButtonCore();
const media = createMediaState({
requestPiP: vi.fn(async () => {
requestPictureInPicture: vi.fn(async () => {
throw new Error('permission denied');
}),
});
@@ -20,8 +20,8 @@ function createMediaState(overrides: Partial<MediaVolumeState> = {}): MediaVolum
volume: 1,
muted: false,
volumeAvailability: 'available',
changeVolume: vi.fn((v: number) => v),
toggleMute: vi.fn(() => false),
setVolume: vi.fn((v: number) => v),
toggleMuted: vi.fn(() => false),
...overrides,
};
}
@@ -3,7 +3,7 @@ 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 {
export function isFullscreenEnabled(): boolean {
const doc = document as WebKitDocument;
// Standard API or WebKit prefix
@@ -27,7 +27,7 @@ export function getFullscreenElement(): Element | null {
*
* Uses `:fullscreen` pseudo-class which works across Shadow DOM boundaries.
*/
export function isElementFullscreen(container: HTMLElement | null, media: HTMLMediaElement): boolean {
export function isFullscreenElement(container: HTMLElement | null, media: HTMLMediaElement): boolean {
const video = media as WebKitVideoElement;
// iOS Safari video-only fullscreen
@@ -49,12 +49,12 @@ export function isElementFullscreen(container: HTMLElement | null, media: HTMLMe
}
/**
* Enter fullscreen mode.
* Request 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> {
export async function requestFullscreen(container: HTMLElement | null, media: HTMLMediaElement): Promise<void> {
const video = media as WebKitVideoElement;
// Try container first (standard and WebKit APIs)
+5 -5
View File
@@ -7,7 +7,7 @@ import type { WebKitVideoElement } from './types';
*
* Note: Safari PWAs don't support PiP even though the API exists.
*/
export function isPiPSupported(): boolean {
export function isPictureInPictureEnabled(): boolean {
// Check standard PiP API
if (document.pictureInPictureEnabled) {
// Safari PWAs have the API but it doesn't work
@@ -24,7 +24,7 @@ export function isPiPSupported(): boolean {
/**
* Check if Picture-in-Picture is currently active for a media element.
*/
export function isPiPActive(media: HTMLMediaElement): boolean {
export function isPictureInPictureElement(media: HTMLMediaElement): boolean {
// Standard PiP API
if (document.pictureInPictureElement === media) {
return true;
@@ -36,12 +36,12 @@ export function isPiPActive(media: HTMLMediaElement): boolean {
}
/**
* Enter Picture-in-Picture mode.
* Request 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> {
export async function requestPictureInPicture(media: HTMLMediaElement): Promise<void> {
const video = media as HTMLVideoElement & WebKitVideoElement;
// Standard PiP API (only available on HTMLVideoElement)
@@ -65,7 +65,7 @@ export async function enterPiP(media: HTMLMediaElement): Promise<void> {
* Uses standard API where available, falls back to iOS Safari's
* WebKit presentation mode.
*/
export async function exitPiP(media?: HTMLMediaElement): Promise<void> {
export async function exitPictureInPicture(media?: HTMLMediaElement): Promise<void> {
// Standard PiP API
if (document.pictureInPictureElement && isFunction(document.exitPictureInPicture)) {
await document.exitPictureInPicture();
@@ -3,12 +3,12 @@ import { listen } from '@videojs/utils/dom';
import type { MediaFullscreenState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
import {
enterFullscreen,
exitFullscreen,
isElementFullscreen,
isFullscreenSupported,
isFullscreenElement,
isFullscreenEnabled,
requestFullscreen,
} from '../../presentation/fullscreen';
import { exitPiP, isPiPActive } from '../../presentation/pip';
import { exitPictureInPicture, isPictureInPictureElement } from '../../presentation/pip';
import type { WebKitVideoElement } from '../../presentation/types';
export const fullscreenFeature = definePlayerFeature({
@@ -21,11 +21,11 @@ export const fullscreenFeature = definePlayerFeature({
const { media, container } = target();
// Exit PiP first if active (browser behavior is inconsistent)
if (isPiPActive(media)) {
await exitPiP(media);
if (isPictureInPictureElement(media)) {
await exitPictureInPicture(media);
}
return enterFullscreen(container, media);
return requestFullscreen(container, media);
},
async exitFullscreen() {
@@ -37,12 +37,12 @@ export const fullscreenFeature = definePlayerFeature({
const { media, container } = target;
set({
fullscreenAvailability: isFullscreenSupported() ? 'available' : 'unsupported',
fullscreenAvailability: isFullscreenEnabled() ? 'available' : 'unsupported',
});
const sync = () =>
set({
fullscreen: isElementFullscreen(container, media),
fullscreen: isFullscreenElement(container, media),
});
sync();
+14 -9
View File
@@ -2,8 +2,13 @@ import { listen } from '@videojs/utils/dom';
import type { MediaPictureInPictureState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
import { exitFullscreen, isElementFullscreen } from '../../presentation/fullscreen';
import { enterPiP, exitPiP, isPiPActive, isPiPSupported } from '../../presentation/pip';
import { exitFullscreen, isFullscreenElement } from '../../presentation/fullscreen';
import {
exitPictureInPicture,
isPictureInPictureElement,
isPictureInPictureEnabled,
requestPictureInPicture,
} from '../../presentation/pip';
import type { WebKitVideoElement } from '../../presentation/types';
export const pipFeature = definePlayerFeature({
@@ -12,20 +17,20 @@ export const pipFeature = definePlayerFeature({
pip: false,
pipAvailability: 'unavailable',
async requestPiP() {
async requestPictureInPicture() {
const { media, container } = target();
// Exit fullscreen first if active
if (isElementFullscreen(container, media)) {
if (isFullscreenElement(container, media)) {
await exitFullscreen();
}
return enterPiP(media);
return requestPictureInPicture(media);
},
async exitPiP() {
async exitPictureInPicture() {
const { media } = target();
return exitPiP(media);
return exitPictureInPicture(media);
},
}),
@@ -33,12 +38,12 @@ export const pipFeature = definePlayerFeature({
const { media } = target;
set({
pipAvailability: isPiPSupported() ? 'available' : 'unsupported',
pipAvailability: isPictureInPictureEnabled() ? 'available' : 'unsupported',
});
const sync = () =>
set({
pip: isPiPActive(media),
pip: isPictureInPictureElement(media),
});
sync();
@@ -31,6 +31,7 @@ export const playbackFeature = definePlayerFeature({
sync();
listen(media, 'emptied', sync, { signal });
listen(media, 'play', sync, { signal });
listen(media, 'pause', sync, { signal });
listen(media, 'ended', sync, { signal });
@@ -85,19 +85,19 @@ describe('pipFeature', () => {
});
describe('actions', () => {
it('requestPiP() calls requestPictureInPicture on video', async () => {
it('requestPictureInPicture() 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();
await store.requestPictureInPicture();
expect(video.requestPictureInPicture).toHaveBeenCalled();
});
it('exitPiP() calls document.exitPictureInPicture', async () => {
it('exitPictureInPicture() calls document.exitPictureInPicture', async () => {
const originalExit = document.exitPictureInPicture;
document.exitPictureInPicture = vi.fn().mockResolvedValue(undefined);
@@ -113,7 +113,7 @@ describe('pipFeature', () => {
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container: null });
await store.exitPiP();
await store.exitPictureInPicture();
expect(document.exitPictureInPicture).toHaveBeenCalled();
@@ -122,7 +122,7 @@ describe('pipFeature', () => {
});
describe('transitions', () => {
it('requestPiP() exits fullscreen first if active', async () => {
it('requestPictureInPicture() exits fullscreen first if active', async () => {
const originalExit = document.exitFullscreen;
document.exitFullscreen = vi.fn().mockResolvedValue(undefined);
@@ -140,7 +140,7 @@ describe('pipFeature', () => {
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container });
await store.requestPiP();
await store.requestPictureInPicture();
expect(document.exitFullscreen).toHaveBeenCalled();
expect(video.requestPictureInPicture).toHaveBeenCalled();
@@ -148,7 +148,7 @@ describe('pipFeature', () => {
document.exitFullscreen = originalExit;
});
it('requestPiP() does not exit fullscreen if not active', async () => {
it('requestPictureInPicture() does not exit fullscreen if not active', async () => {
const originalExit = document.exitFullscreen;
document.exitFullscreen = vi.fn().mockResolvedValue(undefined);
@@ -158,7 +158,7 @@ describe('pipFeature', () => {
const store = createStore<PlayerTarget>()(pipFeature);
store.attach({ media: video, container: null });
await store.requestPiP();
await store.requestPictureInPicture();
expect(document.exitFullscreen).not.toHaveBeenCalled();
expect(video.requestPictureInPicture).toHaveBeenCalled();
@@ -47,13 +47,13 @@ describe('volumeFeature', () => {
});
describe('actions', () => {
describe('changeVolume', () => {
describe('setVolume', () => {
it('sets volume on target', async () => {
const video = createMockVideo({});
const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null });
const result = await store.changeVolume(0.7);
const result = await store.setVolume(0.7);
expect(video.volume).toBe(0.7);
expect(result).toBe(0.7);
@@ -64,7 +64,7 @@ describe('volumeFeature', () => {
const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null });
await store.changeVolume(-0.5);
await store.setVolume(-0.5);
expect(video.volume).toBe(0);
});
@@ -74,19 +74,19 @@ describe('volumeFeature', () => {
const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null });
await store.changeVolume(1.5);
await store.setVolume(1.5);
expect(video.volume).toBe(1);
});
});
describe('toggleMute', () => {
describe('toggleMuted', () => {
it('toggles mute from false to true', async () => {
const video = createMockVideo({ muted: false });
const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null });
const result = await store.toggleMute();
const result = await store.toggleMuted();
expect(video.muted).toBe(true);
expect(result).toBe(true);
@@ -97,7 +97,7 @@ describe('volumeFeature', () => {
const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null });
const result = await store.toggleMute();
const result = await store.toggleMuted();
expect(video.muted).toBe(false);
expect(result).toBe(false);
@@ -10,13 +10,13 @@ export const volumeFeature = definePlayerFeature({
muted: false,
volumeAvailability: 'unavailable',
changeVolume(volume: number) {
setVolume(volume: number) {
const { media } = target();
media.volume = Math.max(0, Math.min(1, volume));
return media.volume;
},
toggleMute() {
toggleMuted() {
const { media } = target();
media.muted = !media.muted;
return media.muted;