feat(packages): add PlaybackRateButton to core, html, and react (#642)

This commit is contained in:
Darius Cepulis
2026-02-27 14:36:11 -06:00
committed by GitHub
parent d1195e971f
commit 0180828df9
26 changed files with 639 additions and 46 deletions
+2
View File
@@ -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';
+21
View File
@@ -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.
@@ -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<PlaybackRateButtonProps> = {
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;
}
@@ -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<PlaybackRateButtonState>;
@@ -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> = {}): MediaPlaybackRateState {
return {
playbackRates: [1, 1.2, 1.5, 1.7, 2],
playbackRate: 1,
setPlaybackRate: vi.fn(),
...overrides,
};
}
function createState(overrides: Partial<PlaybackRateButtonState> = {}): 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();
});
});
});
+3
View File
@@ -4,6 +4,7 @@ import type {
MediaControlsState,
MediaFullscreenState,
MediaPictureInPictureState,
MediaPlaybackRateState,
MediaPlaybackState,
MediaSourceState,
MediaTimeState,
@@ -44,6 +45,7 @@ export type AnyPlayerStore = Store<PlayerTarget, object>;
export type VideoFeatures = [
PlayerFeature<MediaPlaybackState>,
PlayerFeature<MediaPlaybackRateState>,
PlayerFeature<MediaVolumeState>,
PlayerFeature<MediaTimeState>,
PlayerFeature<MediaSourceState>,
@@ -55,6 +57,7 @@ export type VideoFeatures = [
export type AudioFeatures = [
PlayerFeature<MediaPlaybackState>,
PlayerFeature<MediaPlaybackRateState>,
PlayerFeature<MediaVolumeState>,
PlayerFeature<MediaTimeState>,
PlayerFeature<MediaSourceState>,
@@ -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 = [];
@@ -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';
@@ -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 });
},
});
+3
View File
@@ -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). */