diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index de2f4f16..1fa0ff8b 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/ui/pip-button/pip-button-core.ts b/packages/core/src/core/ui/pip-button/pip-button-core.ts new file mode 100644 index 00000000..857d3887 --- /dev/null +++ b/packages/core/src/core/ui/pip-button/pip-button-core.ts @@ -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 { + /** Whether picture-in-picture can be requested on this platform. */ + availability: MediaPictureInPictureState['pipAvailability']; +} + +export class PiPButtonCore { + static readonly defaultProps: NonNullableObject = { + 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 { + 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; +} diff --git a/packages/core/src/core/ui/pip-button/pip-button-data-attrs.ts b/packages/core/src/core/ui/pip-button/pip-button-data-attrs.ts new file mode 100644 index 00000000..82586a98 --- /dev/null +++ b/packages/core/src/core/ui/pip-button/pip-button-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts new file mode 100644 index 00000000..d70352c3 --- /dev/null +++ b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts @@ -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 { + return { + pip: false, + pipAvailability: 'available', + requestPiP: vi.fn(async () => {}), + exitPiP: vi.fn(async () => {}), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): 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(); + }); + }); +}); diff --git a/packages/html/src/define/ui/pip-button.ts b/packages/html/src/define/ui/pip-button.ts new file mode 100644 index 00000000..11a84795 --- /dev/null +++ b/packages/html/src/define/ui/pip-button.ts @@ -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; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index e6262cb3..33221e1e 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/pip-button/pip-button-element.ts b/packages/html/src/ui/pip-button/pip-button-element.ts new file mode 100644 index 00000000..937065da --- /dev/null +++ b/packages/html/src/ui/pip-button/pip-button-element.ts @@ -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; + + 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); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8712348d..6a6d33af 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/ui/pip-button/pip-button.tsx b/packages/react/src/ui/pip-button/pip-button.tsx new file mode 100644 index 00000000..d9d6464e --- /dev/null +++ b/packages/react/src/ui/pip-button/pip-button.tsx @@ -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 +) { + 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; +}