From 0180828df91ad74f885906223fa7d359f1a2641a Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Fri, 27 Feb 2026 14:36:11 -0600 Subject: [PATCH] feat(packages): add PlaybackRateButton to core, html, and react (#642) --- internal/design/ui/playback-rate-button.md | 5 +- packages/core/src/core/index.ts | 2 + packages/core/src/core/media/state.ts | 21 +++ .../playback-rate-button-core.ts | 79 ++++++++++ .../playback-rate-button-data-attrs.ts | 7 + .../tests/playback-rate-button-core.test.ts | 142 ++++++++++++++++++ packages/core/src/dom/media/types.ts | 3 + .../src/dom/store/features/feature.parts.ts | 12 +- packages/core/src/dom/store/features/index.ts | 1 + .../src/dom/store/features/playback-rate.ts | 25 +++ packages/core/src/dom/store/selectors.ts | 3 + .../src/define/ui/playback-rate-button.ts | 9 ++ packages/html/src/index.ts | 1 + .../playback-rate-button-element.ts | 70 +++++++++ packages/react/src/index.ts | 1 + .../src/ui/playback-rate-button/index.ts | 6 +- .../playback-rate-button.tsx | 59 +++----- .../html/css/BasicUsage.astro | 10 ++ .../html/css/BasicUsage.css | 25 +++ .../html/css/BasicUsage.html | 11 ++ .../html/css/BasicUsage.ts | 2 + .../react/css/BasicUsage.css | 21 +++ .../react/css/BasicUsage.tsx | 26 ++++ .../docs/reference/playback-rate-button.mdx | 95 ++++++++++++ .../docs/reference/select-playback-rate.mdx | 47 ++++++ site/src/docs.config.ts | 2 + 26 files changed, 639 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts create mode 100644 packages/core/src/core/ui/playback-rate-button/playback-rate-button-data-attrs.ts create mode 100644 packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts create mode 100644 packages/core/src/dom/store/features/playback-rate.ts create mode 100644 packages/html/src/define/ui/playback-rate-button.ts create mode 100644 packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts create mode 100644 site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/playback-rate-button/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/playback-rate-button/react/css/BasicUsage.tsx create mode 100644 site/src/content/docs/reference/playback-rate-button.mdx create mode 100644 site/src/content/docs/reference/select-playback-rate.mdx diff --git a/internal/design/ui/playback-rate-button.md b/internal/design/ui/playback-rate-button.md index c8ec8ca6..eee9ca78 100644 --- a/internal/design/ui/playback-rate-button.md +++ b/internal/design/ui/playback-rate-button.md @@ -50,6 +50,7 @@ export const playbackRateFeature = createFeature({ const { media } = target; const sync = () => set({ playbackRate: media.playbackRate }); + sync(); listen(media, "ratechange", sync, { signal }); }, @@ -83,7 +84,7 @@ import { PlaybackRateButton } from "@videojs/react"; /* With rate display */ } } + render={(props, state) => } />; ``` @@ -96,7 +97,7 @@ import { PlaybackRateButton } from "@videojs/react"; ```css /* Rate display via CSS */ media-playback-rate-button::after { - content: attr(data-rate) "x"; + content: attr(data-rate) "\00D7"; } ``` diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 7d757dca..e753f4a0 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -12,6 +12,8 @@ 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/playback-rate-button/playback-rate-button-core'; +export * from './ui/playback-rate-button/playback-rate-button-data-attrs'; export * from './ui/poster/poster-core'; export * from './ui/poster/poster-data-attrs'; export * from './ui/seek-button/seek-button-core'; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 7fc43c81..ee4ec1ed 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -170,6 +170,27 @@ export interface MediaControlsState { controlsVisible: boolean; } +export interface MediaPlaybackRateState { + /** + * Available playback rates. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/playbackRate + */ + readonly playbackRates: readonly number[]; + /** + * Current playback rate. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/playbackRate + */ + playbackRate: number; + /** + * Set the playback rate. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/playbackRate + */ + setPlaybackRate(rate: number): void; +} + export interface MediaPictureInPictureState { /** * Whether picture-in-picture mode is currently active. diff --git a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts new file mode 100644 index 00000000..16b42cd9 --- /dev/null +++ b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts @@ -0,0 +1,79 @@ +import { defaults } from '@videojs/utils/object'; +import { isFunction } from '@videojs/utils/predicate'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaPlaybackRateState } from '../../media/state'; + +export interface PlaybackRateButtonProps { + /** Custom label for the button. */ + label?: string | ((state: PlaybackRateButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export interface PlaybackRateButtonState { + rate: number; +} + +export class PlaybackRateButtonCore { + static readonly defaultProps: NonNullableObject = { + label: '', + disabled: false, + }; + + #props = { ...PlaybackRateButtonCore.defaultProps }; + + constructor(props?: PlaybackRateButtonProps) { + if (props) this.setProps(props); + } + + setProps(props: PlaybackRateButtonProps): void { + this.#props = defaults(props, PlaybackRateButtonCore.defaultProps); + } + + getLabel(state: PlaybackRateButtonState): string { + const { label } = this.#props; + + if (isFunction(label)) { + const customLabel = label(state); + if (customLabel) return customLabel; + } else if (label) { + return label; + } + + return `Playback rate ${state.rate}`; + } + + getAttrs(state: PlaybackRateButtonState) { + return { + 'aria-label': this.getLabel(state), + 'aria-disabled': this.#props.disabled ? 'true' : undefined, + }; + } + + getState(media: MediaPlaybackRateState): PlaybackRateButtonState { + return { + rate: media.playbackRate, + }; + } + + cycle(media: MediaPlaybackRateState): void { + if (this.#props.disabled) return; + + const { playbackRates, playbackRate } = media; + if (playbackRates.length === 0) return; + + const idx = playbackRates.indexOf(playbackRate); + const next = + idx === -1 + ? (playbackRates.find((r) => r > playbackRate) ?? playbackRates[0]!) + : playbackRates[(idx + 1) % playbackRates.length]!; + + media.setPlaybackRate(next); + } +} + +export namespace PlaybackRateButtonCore { + export type Props = PlaybackRateButtonProps; + export type State = PlaybackRateButtonState; +} diff --git a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-data-attrs.ts b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-data-attrs.ts new file mode 100644 index 00000000..b4b62745 --- /dev/null +++ b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-data-attrs.ts @@ -0,0 +1,7 @@ +import type { StateAttrMap } from '../types'; +import type { PlaybackRateButtonState } from './playback-rate-button-core'; + +export const PlaybackRateButtonDataAttrs = { + /** Current playback rate. */ + rate: 'data-rate', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts new file mode 100644 index 00000000..16a1f32d --- /dev/null +++ b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MediaPlaybackRateState } from '../../../media/state'; +import type { PlaybackRateButtonState } from '../playback-rate-button-core'; +import { PlaybackRateButtonCore } from '../playback-rate-button-core'; + +function createMediaState(overrides: Partial = {}): MediaPlaybackRateState { + return { + playbackRates: [1, 1.2, 1.5, 1.7, 2], + playbackRate: 1, + setPlaybackRate: vi.fn(), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): PlaybackRateButtonState { + return { + rate: 1, + ...overrides, + }; +} + +describe('PlaybackRateButtonCore', () => { + describe('getState', () => { + it('projects playbackRate to rate', () => { + const core = new PlaybackRateButtonCore(); + const media = createMediaState({ playbackRate: 1.5 }); + const state = core.getState(media); + + expect(state.rate).toBe(1.5); + }); + }); + + describe('getLabel', () => { + it('returns default label with rate', () => { + const core = new PlaybackRateButtonCore(); + expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate 1.5'); + }); + + it('returns default label for rate 1', () => { + const core = new PlaybackRateButtonCore(); + expect(core.getLabel(createState({ rate: 1 }))).toBe('Playback rate 1'); + }); + + it('returns custom string label', () => { + const core = new PlaybackRateButtonCore({ label: 'Speed' }); + expect(core.getLabel(createState())).toBe('Speed'); + }); + + it('returns custom function label', () => { + const core = new PlaybackRateButtonCore({ + label: (state) => `${state.rate}x speed`, + }); + expect(core.getLabel(createState({ rate: 2 }))).toBe('2x speed'); + }); + + it('falls back to default when function returns empty string', () => { + const core = new PlaybackRateButtonCore({ + label: () => '', + }); + expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate 1.5'); + }); + }); + + describe('getAttrs', () => { + it('returns aria-label', () => { + const core = new PlaybackRateButtonCore(); + const attrs = core.getAttrs(createState({ rate: 1.5 })); + expect(attrs['aria-label']).toBe('Playback rate 1.5'); + }); + + it('sets aria-disabled when disabled', () => { + const core = new PlaybackRateButtonCore({ disabled: true }); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBe('true'); + }); + + it('does not set aria-disabled when not disabled', () => { + const core = new PlaybackRateButtonCore(); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBeUndefined(); + }); + }); + + describe('cycle', () => { + it('advances to the next rate', () => { + const core = new PlaybackRateButtonCore(); + const media = createMediaState({ playbackRate: 1 }); + core.cycle(media); + expect(media.setPlaybackRate).toHaveBeenCalledWith(1.2); + }); + + it('wraps to the first rate after the last', () => { + const core = new PlaybackRateButtonCore(); + const media = createMediaState({ playbackRate: 2 }); + core.cycle(media); + expect(media.setPlaybackRate).toHaveBeenCalledWith(1); + }); + + it('advances through the middle of the list', () => { + const core = new PlaybackRateButtonCore(); + const media = createMediaState({ playbackRate: 1.5 }); + core.cycle(media); + expect(media.setPlaybackRate).toHaveBeenCalledWith(1.7); + }); + + it('finds the first rate greater than current when not in list', () => { + const core = new PlaybackRateButtonCore(); + const media = createMediaState({ playbackRate: 0.75 }); + core.cycle(media); + expect(media.setPlaybackRate).toHaveBeenCalledWith(1); + }); + + it('finds the next greater rate when between list values', () => { + const core = new PlaybackRateButtonCore(); + const media = createMediaState({ playbackRate: 1.3 }); + core.cycle(media); + expect(media.setPlaybackRate).toHaveBeenCalledWith(1.5); + }); + + it('wraps to first rate when current is above all rates and not in list', () => { + const core = new PlaybackRateButtonCore(); + const media = createMediaState({ playbackRate: 3 }); + core.cycle(media); + expect(media.setPlaybackRate).toHaveBeenCalledWith(1); + }); + + it('does nothing when disabled', () => { + const core = new PlaybackRateButtonCore({ disabled: true }); + const media = createMediaState(); + core.cycle(media); + expect(media.setPlaybackRate).not.toHaveBeenCalled(); + }); + + it('does nothing when playbackRates is empty', () => { + const core = new PlaybackRateButtonCore(); + const media = createMediaState({ playbackRates: [] }); + core.cycle(media); + expect(media.setPlaybackRate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index 4a6601e4..61ccb8e5 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -4,6 +4,7 @@ import type { MediaControlsState, MediaFullscreenState, MediaPictureInPictureState, + MediaPlaybackRateState, MediaPlaybackState, MediaSourceState, MediaTimeState, @@ -44,6 +45,7 @@ export type AnyPlayerStore = Store; export type VideoFeatures = [ PlayerFeature, + PlayerFeature, PlayerFeature, PlayerFeature, PlayerFeature, @@ -55,6 +57,7 @@ export type VideoFeatures = [ export type AudioFeatures = [ PlayerFeature, + PlayerFeature, PlayerFeature, PlayerFeature, PlayerFeature, diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index 55b34265..36feabaf 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -4,6 +4,7 @@ import { controlsFeature } from './controls'; import { fullscreenFeature } from './fullscreen'; import { pipFeature } from './pip'; import { playbackFeature } from './playback'; +import { playbackRateFeature } from './playback-rate'; import { sourceFeature } from './source'; import { timeFeature } from './time'; import { volumeFeature } from './volume'; @@ -15,6 +16,7 @@ export { fullscreenFeature as fullscreen, pipFeature as pip, playbackFeature as playback, + playbackRateFeature as playbackRate, sourceFeature as source, timeFeature as time, volumeFeature as volume, @@ -22,6 +24,7 @@ export { export const video: VideoFeatures = [ playbackFeature, + playbackRateFeature, volumeFeature, timeFeature, sourceFeature, @@ -31,7 +34,14 @@ export const video: VideoFeatures = [ controlsFeature, ]; -export const audio: AudioFeatures = [playbackFeature, volumeFeature, timeFeature, sourceFeature, bufferFeature]; +export const audio: AudioFeatures = [ + playbackFeature, + playbackRateFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, +]; // TODO: Add background video features (e.g., playback, source, buffer) export const background: BackgroundFeatures = []; diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index d993c9f4..f303e71a 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -4,6 +4,7 @@ export * as features from './feature.parts'; export * from './fullscreen'; export * from './pip'; export * from './playback'; +export * from './playback-rate'; export * from './source'; export * from './time'; export * from './volume'; diff --git a/packages/core/src/dom/store/features/playback-rate.ts b/packages/core/src/dom/store/features/playback-rate.ts new file mode 100644 index 00000000..6aff6f0b --- /dev/null +++ b/packages/core/src/dom/store/features/playback-rate.ts @@ -0,0 +1,25 @@ +import { listen } from '@videojs/utils/dom'; + +import type { MediaPlaybackRateState } from '../../../core/media/state'; +import { definePlayerFeature } from '../../feature'; + +const DEFAULT_RATES: readonly number[] = [1, 1.2, 1.5, 1.7, 2]; + +export const playbackRateFeature = definePlayerFeature({ + state: ({ target }): MediaPlaybackRateState => ({ + playbackRates: DEFAULT_RATES, + playbackRate: 1, + setPlaybackRate(rate: number) { + target().media.playbackRate = rate; + }, + }), + + attach({ target, signal, set }) { + const { media } = target; + + const sync = () => set({ playbackRate: media.playbackRate }); + sync(); + + listen(media, 'ratechange', sync, { signal }); + }, +}); diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 3cb3d1f7..967d3414 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -5,6 +5,7 @@ import { controlsFeature } from './features/controls'; import { fullscreenFeature } from './features/fullscreen'; import { pipFeature } from './features/pip'; import { playbackFeature } from './features/playback'; +import { playbackRateFeature } from './features/playback-rate'; import { sourceFeature } from './features/source'; import { timeFeature } from './features/time'; import { volumeFeature } from './features/volume'; @@ -19,6 +20,8 @@ export const selectFullscreen = createSelector(fullscreenFeature); export const selectPiP = createSelector(pipFeature); /** Select the playback state (paused, ended, play, pause, toggle). */ export const selectPlayback = createSelector(playbackFeature); +/** Select the playback rate state (playbackRate, playbackRates, setPlaybackRate). */ +export const selectPlaybackRate = createSelector(playbackRateFeature); /** Select the source state (src, type). */ export const selectSource = createSelector(sourceFeature); /** Select the time state (currentTime, duration, seek). */ diff --git a/packages/html/src/define/ui/playback-rate-button.ts b/packages/html/src/define/ui/playback-rate-button.ts new file mode 100644 index 00000000..48f68c3e --- /dev/null +++ b/packages/html/src/define/ui/playback-rate-button.ts @@ -0,0 +1,9 @@ +import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; + +customElements.define(PlaybackRateButtonElement.tagName, PlaybackRateButtonElement); + +declare global { + interface HTMLElementTagNameMap { + [PlaybackRateButtonElement.tagName]: PlaybackRateButtonElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 96b50c0d..7f496297 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -23,6 +23,7 @@ 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 { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-rate-button-element'; export { PosterElement } from './ui/poster/poster-element'; export { SeekButtonElement } from './ui/seek-button/seek-button-element'; export { TimeElement } from './ui/time/time-element'; diff --git a/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts b/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts new file mode 100644 index 00000000..719fefab --- /dev/null +++ b/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts @@ -0,0 +1,70 @@ +import { PlaybackRateButtonCore, PlaybackRateButtonDataAttrs } from '@videojs/core'; +import { + applyElementProps, + applyStateDataAttrs, + createButton, + logMissingFeature, + selectPlaybackRate, +} 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 PlaybackRateButtonElement extends MediaElement { + static readonly tagName = 'media-playback-rate-button'; + + static override properties = { + label: { type: String }, + disabled: { type: Boolean }, + } satisfies PropertyDeclarationMap; + + label = PlaybackRateButtonCore.defaultProps.label; + disabled = PlaybackRateButtonCore.defaultProps.disabled; + + readonly #core = new PlaybackRateButtonCore(); + readonly #state = new PlayerController(this, playerContext, selectPlaybackRate); + + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + + this.#disconnect = new AbortController(); + + const buttonProps = createButton({ + onActivate: () => this.#core.cycle(this.#state.value!), + isDisabled: () => this.disabled || !this.#state.value, + }); + + applyElementProps(this, buttonProps, this.#disconnect.signal); + + if (__DEV__ && !this.#state.value) { + logMissingFeature(PlaybackRateButtonElement.tagName, 'playbackRate'); + } + } + + 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, PlaybackRateButtonDataAttrs); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index db02da9f..5a232f51 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -37,6 +37,7 @@ 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 { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback-rate-button/playback-rate-button'; export { Poster, type PosterProps } from './ui/poster/poster'; export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button'; export { Time } from './ui/time'; diff --git a/packages/react/src/ui/playback-rate-button/index.ts b/packages/react/src/ui/playback-rate-button/index.ts index e4dbc494..4eb89f15 100644 --- a/packages/react/src/ui/playback-rate-button/index.ts +++ b/packages/react/src/ui/playback-rate-button/index.ts @@ -1,5 +1 @@ -export { - PlaybackRateButton, - type PlaybackRateButtonProps, - type PlaybackRateButtonState, -} from './playback-rate-button'; +export { PlaybackRateButton, type PlaybackRateButtonProps } from './playback-rate-button'; diff --git a/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx b/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx index 788a77cc..af82b596 100644 --- a/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx +++ b/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx @@ -1,38 +1,21 @@ 'use client'; -import type { StateAttrMap } from '@videojs/core'; +import { PlaybackRateButtonCore, PlaybackRateButtonDataAttrs } from '@videojs/core'; +import { logMissingFeature, selectPlaybackRate } 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'; -const RATES = [0.5, 1, 1.2, 1.5, 1.7, 2] as const; - -// FIXME: Replace with state/props from core. -export type PlaybackRateButtonState = { - rate: (typeof RATES)[number]; -}; -type PlaybackRateButtonCoreProps = { - /** Custom label for the button. */ - label?: string | ((state: PlaybackRateButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -}; -const PlaybackRateButtonDataAttrs = { - /** Present when the playback rate is active. */ - rate: 'data-playback-rate', -} as const satisfies StateAttrMap; - export interface PlaybackRateButtonProps - extends UIComponentProps<'button', PlaybackRateButtonState>, - PlaybackRateButtonCoreProps {} - -const DEBUG = true; + extends UIComponentProps<'button', PlaybackRateButtonCore.State>, + PlaybackRateButtonCore.Props {} /** - * A button that toggles playback rate. + * A button that cycles through playback rates. * * @example * ```tsx @@ -53,37 +36,37 @@ export const PlaybackRateButton = forwardRef(function PlaybackRateButton( ) { const { render, className, style, label, disabled, ...elementProps } = componentProps; - // FIXME: Replace with actual playback rate state - const [rate, setRate] = useState(1); + const playbackRate = usePlayer(selectPlaybackRate); + + const [core] = useState(() => new PlaybackRateButtonCore()); + core.setProps({ label, disabled }); const { getButtonProps, buttonRef } = useButton({ displayName: 'PlaybackRateButton', - onActivate: () => - setRate((currentRate) => { - // FIXME: Replace with actual toggle logic - const rates: PlaybackRateButtonState['rate'][] = [...RATES]; - const currentIndex = rates.indexOf(currentRate); - const nextIndex = (currentIndex + 1) % rates.length; - return rates[nextIndex] ?? 1; - }), - isDisabled: () => disabled ?? false, + onActivate: () => core.cycle(playbackRate!), + isDisabled: () => disabled || !playbackRate, }); - if (!DEBUG) return null; + if (!playbackRate) { + if (__DEV__) logMissingFeature('PlaybackRateButton', 'playbackRate'); + return null; + } + + const state = core.getState(playbackRate); return renderElement( 'button', { render, className, style }, { - state: { rate }, // FIXME: Replace with actual toggle logic + state, stateAttrMap: PlaybackRateButtonDataAttrs, ref: [forwardedRef, buttonRef], - props: [elementProps, getButtonProps()], + props: [core.getAttrs(state), elementProps, getButtonProps()], } ); }); export namespace PlaybackRateButton { export type Props = PlaybackRateButtonProps; - export type State = PlaybackRateButtonState; + export type State = PlaybackRateButtonCore.State; } diff --git a/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.astro b/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.astro new file mode 100644 index 00000000..e7e95f49 --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.astro @@ -0,0 +1,10 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +import './BasicUsage.css'; +--- + + + diff --git a/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.css b/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.css new file mode 100644 index 00000000..d91b8a9a --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.css @@ -0,0 +1,25 @@ +.html-playback-rate-button-basic { + position: relative; +} + +.html-playback-rate-button-basic video { + width: 100%; +} + +.html-playback-rate-button-basic__button { + padding-block: 8px; + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + color: black; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + padding-inline: 20px; + cursor: pointer; +} + +.html-playback-rate-button-basic__button::after { + content: attr(data-rate) "\00D7"; +} diff --git a/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.html b/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.html new file mode 100644 index 00000000..c99fda43 --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.html @@ -0,0 +1,11 @@ + + + + + diff --git a/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.ts b/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.ts new file mode 100644 index 00000000..6f30f8d6 --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-button/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/playback-rate-button'; diff --git a/site/src/components/docs/demos/playback-rate-button/react/css/BasicUsage.css b/site/src/components/docs/demos/playback-rate-button/react/css/BasicUsage.css new file mode 100644 index 00000000..0a980730 --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-button/react/css/BasicUsage.css @@ -0,0 +1,21 @@ +.react-playback-rate-button-basic { + position: relative; +} + +.react-playback-rate-button-basic video { + width: 100%; +} + +.react-playback-rate-button-basic__button { + padding-block: 8px; + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + color: black; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + padding-inline: 20px; + cursor: pointer; +} diff --git a/site/src/components/docs/demos/playback-rate-button/react/css/BasicUsage.tsx b/site/src/components/docs/demos/playback-rate-button/react/css/BasicUsage.tsx new file mode 100644 index 00000000..880a8c7a --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-button/react/css/BasicUsage.tsx @@ -0,0 +1,26 @@ +import { createPlayer, features, PlaybackRateButton } from '@videojs/react'; +import { Video } from '@videojs/react/video'; + +import './BasicUsage.css'; + +const Player = createPlayer({ features: [...features.video] }); + +export default function BasicUsage() { + return ( + + + + + ); +} diff --git a/site/src/content/docs/reference/playback-rate-button.mdx b/site/src/content/docs/reference/playback-rate-button.mdx new file mode 100644 index 00000000..e785a8d1 --- /dev/null +++ b/site/src/content/docs/reference/playback-rate-button.mdx @@ -0,0 +1,95 @@ +--- +title: PlaybackRateButton +frameworkTitle: + html: media-playback-rate-button +description: A button that cycles through playback speed rates +--- + +import ComponentReference from "@/components/docs/api-reference/ComponentReference.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; +import StyleCase from "@/components/docs/StyleCase.astro"; +import Demo from "@/components/docs/demos/Demo.astro"; + +{/* React demos */} +import BasicUsageDemoReact from "@/components/docs/demos/playback-rate-button/react/css/BasicUsage"; +import basicUsageReactTsx from "@/components/docs/demos/playback-rate-button/react/css/BasicUsage.tsx?raw"; +import basicUsageReactCss from "@/components/docs/demos/playback-rate-button/react/css/BasicUsage.css?raw"; + +{/* HTML demos */} +import BasicUsageDemoHtml from "@/components/docs/demos/playback-rate-button/html/css/BasicUsage.astro"; +import basicUsageHtml from "@/components/docs/demos/playback-rate-button/html/css/BasicUsage.html?raw"; +import basicUsageHtmlCss from "@/components/docs/demos/playback-rate-button/html/css/BasicUsage.css?raw"; +import basicUsageHtmlTs from "@/components/docs/demos/playback-rate-button/html/css/BasicUsage.ts?raw"; + +## Anatomy + + +```tsx + +``` + + + +```html + +``` + + +## Behavior + +Cycles through playback rates on click. The default rate list is `[1, 1.2, 1.5, 1.7, 2]`. After the last rate, it wraps back to the first. If the current rate isn't in the list (e.g., set programmatically), the button jumps to the next rate greater than the current one. + +## Styling + +Display the current rate using the `data-rate` attribute: + + +```css +media-playback-rate-button::after { + content: attr(data-rate) "\00D7"; +} +``` + + + +Use the `render` prop for rate display: + +```tsx + } +/> +``` + + +## Accessibility + +Renders a `