mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 21:57:29 +00:00
feat(core): add pip button component (#525)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PiPButtonElement } from '../../ui/pip-button/pip-button-element';
|
||||
|
||||
customElements.define(PiPButtonElement.tagName, PiPButtonElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[PiPButtonElement.tagName]: PiPButtonElement;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-butto
|
||||
// Primitives
|
||||
export * from './ui/media-element';
|
||||
export { MuteButtonElement } from './ui/mute-button/mute-button-element';
|
||||
export { PiPButtonElement } from './ui/pip-button/pip-button-element';
|
||||
export { PlayButtonElement } from './ui/play-button/play-button-element';
|
||||
export { PosterElement } from './ui/poster/poster-element';
|
||||
export { TimeElement } from './ui/time/time-element';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { PiPButtonCore, PiPButtonDataAttrs } from '@videojs/core';
|
||||
import { applyElementProps, applyStateDataAttrs, createButton, logMissingFeature, selectPiP } from '@videojs/core/dom';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
|
||||
import { playerContext } from '../../player/context';
|
||||
import { PlayerController } from '../../player/player-controller';
|
||||
import { MediaElement } from '../media-element';
|
||||
|
||||
export class PiPButtonElement extends MediaElement {
|
||||
static readonly tagName = 'media-pip-button';
|
||||
|
||||
static override properties = {
|
||||
label: { type: String },
|
||||
disabled: { type: Boolean },
|
||||
} satisfies PropertyDeclarationMap<keyof PiPButtonCore.Props>;
|
||||
|
||||
label = PiPButtonCore.defaultProps.label;
|
||||
disabled = PiPButtonCore.defaultProps.disabled;
|
||||
|
||||
readonly #core = new PiPButtonCore();
|
||||
readonly #state = new PlayerController(this, playerContext, selectPiP);
|
||||
|
||||
#disconnect: AbortController | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.#disconnect = new AbortController();
|
||||
|
||||
const buttonProps = createButton({
|
||||
onActivate: () => this.#core.toggle(this.#state.value!),
|
||||
isDisabled: () => this.disabled || !this.#state.value,
|
||||
});
|
||||
|
||||
applyElementProps(this, buttonProps, this.#disconnect.signal);
|
||||
|
||||
if (__DEV__ && !this.#state.value) {
|
||||
logMissingFeature(PiPButtonElement.tagName, 'pip');
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.#disconnect?.abort();
|
||||
this.#disconnect = null;
|
||||
}
|
||||
|
||||
protected override willUpdate(changed: PropertyValues): void {
|
||||
super.willUpdate(changed);
|
||||
this.#core.setProps(this);
|
||||
}
|
||||
|
||||
protected override update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
|
||||
const media = this.#state.value;
|
||||
|
||||
if (!media) return;
|
||||
|
||||
const state = this.#core.getState(media);
|
||||
applyElementProps(this, this.#core.getAttrs(state));
|
||||
applyStateDataAttrs(this, state, PiPButtonDataAttrs);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export type { ControlsRootProps } from './ui/controls/controls-root';
|
||||
export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-button/fullscreen-button';
|
||||
export { useButton } from './ui/hooks/use-button';
|
||||
export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button';
|
||||
export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button';
|
||||
export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button';
|
||||
export { Poster, type PosterProps } from './ui/poster/poster';
|
||||
export { Time } from './ui/time';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { PiPButtonCore, PiPButtonDataAttrs } from '@videojs/core';
|
||||
import { logMissingFeature, selectPiP } from '@videojs/core/dom';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useState } from 'react';
|
||||
|
||||
import { usePlayer } from '../../player/context';
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
import { useButton } from '../hooks/use-button';
|
||||
|
||||
export interface PiPButtonProps extends UIComponentProps<'button', PiPButtonCore.State>, PiPButtonCore.Props {}
|
||||
|
||||
export const PiPButton = forwardRef(function PiPButton(
|
||||
componentProps: PiPButtonProps,
|
||||
forwardedRef: ForwardedRef<HTMLButtonElement>
|
||||
) {
|
||||
const { render, className, style, label, disabled, ...elementProps } = componentProps;
|
||||
|
||||
const pip = usePlayer(selectPiP);
|
||||
|
||||
const [core] = useState(() => new PiPButtonCore());
|
||||
core.setProps({ label, disabled });
|
||||
|
||||
const { getButtonProps, buttonRef } = useButton({
|
||||
displayName: 'PiPButton',
|
||||
onActivate: () => core.toggle(pip!),
|
||||
isDisabled: () => disabled || !pip,
|
||||
});
|
||||
|
||||
if (!pip) {
|
||||
if (__DEV__) logMissingFeature('PiPButton', 'pip');
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = core.getState(pip);
|
||||
|
||||
return renderElement(
|
||||
'button',
|
||||
{ render, className, style },
|
||||
{
|
||||
state,
|
||||
stateAttrMap: PiPButtonDataAttrs,
|
||||
ref: [forwardedRef, buttonRef],
|
||||
props: [core.getAttrs(state), elementProps, getButtonProps()],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace PiPButton {
|
||||
export type Props = PiPButtonProps;
|
||||
export type State = PiPButtonCore.State;
|
||||
}
|
||||
Reference in New Issue
Block a user