feat(core): add pip button component (#525)

This commit is contained in:
rahim
2026-02-13 22:21:09 +11:00
committed by GitHub
parent 7b41930eed
commit 2c8b77af45
9 changed files with 342 additions and 0 deletions
+2
View File
@@ -5,6 +5,8 @@ export * from './ui/fullscreen-button/fullscreen-button-core';
export * from './ui/fullscreen-button/fullscreen-button-data-attrs';
export * from './ui/mute-button/mute-button-core';
export * from './ui/mute-button/mute-button-data-attrs';
export * from './ui/pip-button/pip-button-core';
export * from './ui/pip-button/pip-button-data-attrs';
export * from './ui/play-button/play-button-core';
export * from './ui/play-button/play-button-data-attrs';
export * from './ui/poster/poster-core';
@@ -0,0 +1,81 @@
import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaPictureInPictureState } from '../../media/state';
export interface PiPButtonProps {
/** Custom label for the button. */
label?: string | ((state: PiPButtonState) => string) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
export interface PiPButtonState extends Pick<MediaPictureInPictureState, 'pip'> {
/** Whether picture-in-picture can be requested on this platform. */
availability: MediaPictureInPictureState['pipAvailability'];
}
export class PiPButtonCore {
static readonly defaultProps: NonNullableObject<PiPButtonProps> = {
label: '',
disabled: false,
};
#props = { ...PiPButtonCore.defaultProps };
constructor(props?: PiPButtonProps) {
if (props) this.setProps(props);
}
setProps(props: PiPButtonProps): void {
this.#props = defaults(props, PiPButtonCore.defaultProps);
}
getLabel(state: PiPButtonState): string {
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(state);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
return state.pip ? 'Exit PiP' : 'Enter PiP';
}
getAttrs(state: PiPButtonState) {
return {
'aria-label': this.getLabel(state),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
};
}
getState(media: MediaPictureInPictureState): PiPButtonState {
return {
pip: media.pip,
availability: media.pipAvailability,
};
}
async toggle(media: MediaPictureInPictureState): Promise<void> {
if (this.#props.disabled) return;
if (media.pipAvailability !== 'available') return;
try {
if (media.pip) {
await media.exitPiP();
} else {
await media.requestPiP();
}
} catch {
// PiP requests can fail (user gesture required, permissions, etc.)
}
}
}
export namespace PiPButtonCore {
export type Props = PiPButtonProps;
export type State = PiPButtonState;
}
@@ -0,0 +1,9 @@
import type { StateAttrMap } from '../types';
import type { PiPButtonState } from './pip-button-core';
export const PiPButtonDataAttrs = {
/** Present when picture-in-picture mode is active. */
pip: 'data-pip',
/** Indicates picture-in-picture availability (`available` or `unsupported`). */
availability: 'data-availability',
} as const satisfies StateAttrMap<PiPButtonState>;
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaPictureInPictureState } from '../../../media/state';
import type { PiPButtonState } from '../pip-button-core';
import { PiPButtonCore } from '../pip-button-core';
function createMediaState(overrides: Partial<MediaPictureInPictureState> = {}): MediaPictureInPictureState {
return {
pip: false,
pipAvailability: 'available',
requestPiP: vi.fn(async () => {}),
exitPiP: vi.fn(async () => {}),
...overrides,
};
}
function createState(overrides: Partial<PiPButtonState> = {}): PiPButtonState {
return {
pip: false,
availability: 'available',
...overrides,
};
}
describe('PiPButtonCore', () => {
describe('getState', () => {
it('projects pip and availability', () => {
const core = new PiPButtonCore();
const media = createMediaState({ pip: true });
const state = core.getState(media);
expect(state.pip).toBe(true);
expect(state.availability).toBe('available');
});
it('reflects unsupported availability', () => {
const core = new PiPButtonCore();
const state = core.getState(createMediaState({ pipAvailability: 'unsupported' }));
expect(state.availability).toBe('unsupported');
});
});
describe('getLabel', () => {
it('returns Enter PiP when not in PiP', () => {
const core = new PiPButtonCore();
expect(core.getLabel(createState({ pip: false }))).toBe('Enter PiP');
});
it('returns Exit PiP when in PiP', () => {
const core = new PiPButtonCore();
expect(core.getLabel(createState({ pip: true }))).toBe('Exit PiP');
});
it('returns custom string label', () => {
const core = new PiPButtonCore({ label: 'Picture-in-picture' });
expect(core.getLabel(createState())).toBe('Picture-in-picture');
});
it('returns custom function label', () => {
const core = new PiPButtonCore({
label: (state) => (state.pip ? 'Leave mini player' : 'Mini player'),
});
expect(core.getLabel(createState({ pip: true }))).toBe('Leave mini player');
});
});
describe('getAttrs', () => {
it('returns aria-label', () => {
const core = new PiPButtonCore();
const attrs = core.getAttrs(createState());
expect(attrs['aria-label']).toBe('Enter PiP');
});
it('sets aria-disabled when disabled', () => {
const core = new PiPButtonCore({ disabled: true });
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBe('true');
});
});
describe('toggle', () => {
it('calls requestPiP when not in PiP', async () => {
const core = new PiPButtonCore();
const media = createMediaState({ pip: false });
await core.toggle(media);
expect(media.requestPiP).toHaveBeenCalled();
});
it('calls exitPiP when in PiP', async () => {
const core = new PiPButtonCore();
const media = createMediaState({ pip: true });
await core.toggle(media);
expect(media.exitPiP).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();
});
it('does nothing when unsupported', async () => {
const core = new PiPButtonCore();
const media = createMediaState({ pipAvailability: 'unsupported' });
await core.toggle(media);
expect(media.requestPiP).not.toHaveBeenCalled();
});
it('catches PiP errors silently', async () => {
const core = new PiPButtonCore();
const media = createMediaState({
requestPiP: vi.fn(async () => {
throw new Error('permission denied');
}),
});
await expect(core.toggle(media)).resolves.toBeUndefined();
});
});
});