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
+3 -2
View File
@@ -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 */
}
<PlaybackRateButton
render={(props, state) => <button {...props}>{state.rate}x</button>}
render={(props, state) => <button {...props}>{state.rate}&times;</button>}
/>;
```
@@ -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";
}
```
+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). */
@@ -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;
}
}
+1
View File
@@ -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';
@@ -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<keyof PlaybackRateButtonCore.Props>;
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);
}
}
+1
View File
@@ -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';
@@ -1,5 +1 @@
export {
PlaybackRateButton,
type PlaybackRateButtonProps,
type PlaybackRateButtonState,
} from './playback-rate-button';
export { PlaybackRateButton, type PlaybackRateButtonProps } from './playback-rate-button';
@@ -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<PlaybackRateButtonState>;
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<PlaybackRateButtonState['rate']>(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;
}
@@ -0,0 +1,10 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
import './BasicUsage.css';
---
<HtmlDemo html={html} />
<script>
import './BasicUsage.ts';
</script>
@@ -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";
}
@@ -0,0 +1,11 @@
<video-player class="html-playback-rate-button-basic">
<video
src="https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4"
autoplay
muted
playsinline
loop
></video>
<media-playback-rate-button class="html-playback-rate-button-basic__button">
</media-playback-rate-button>
</video-player>
@@ -0,0 +1,2 @@
import '@videojs/html/video/player';
import '@videojs/html/ui/playback-rate-button';
@@ -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;
}
@@ -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 (
<Player.Provider>
<Player.Container className="react-playback-rate-button-basic">
<Video
src="https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4"
autoPlay
muted
playsInline
loop
/>
<PlaybackRateButton
className="react-playback-rate-button-basic__button"
render={(props, state) => <button {...props}>{Math.round(state.rate * 10) / 10}&times;</button>}
/>
</Player.Container>
</Player.Provider>
);
}
@@ -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
<FrameworkCase frameworks={["react"]}>
```tsx
<PlaybackRateButton />
```
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
```html
<media-playback-rate-button></media-playback-rate-button>
```
</FrameworkCase>
## 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:
<FrameworkCase frameworks={["html"]}>
```css
media-playback-rate-button::after {
content: attr(data-rate) "\00D7";
}
```
</FrameworkCase>
<FrameworkCase frameworks={["react"]}>
Use the `render` prop for rate display:
```tsx
<PlaybackRateButton
render={(props, state) => <button {...props}>{state.rate}&times;</button>}
/>
```
</FrameworkCase>
## Accessibility
Renders a `<button>` with an automatic `aria-label` of `"Playback rate {rate}"` (e.g., `"Playback rate 1.5"`). Override with the `label` prop. Keyboard activation: <kbd>Enter</kbd> / <kbd>Space</kbd>.
## Examples
### Basic Usage
<FrameworkCase frameworks={["react"]}>
<StyleCase styles={["css"]}>
<Demo files={[
{ title: "App.tsx", code: basicUsageReactTsx, lang: "tsx" },
{ title: "App.css", code: basicUsageReactCss, lang: "css" },
]}>
<BasicUsageDemoReact client:idle />
</Demo>
</StyleCase>
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
<StyleCase styles={["css"]}>
<Demo files={[
{ title: "index.html", code: basicUsageHtml, lang: "html" },
{ title: "index.css", code: basicUsageHtmlCss, lang: "css" },
{ title: "index.ts", code: basicUsageHtmlTs, lang: "ts" },
]}>
<BasicUsageDemoHtml />
</Demo>
</StyleCase>
</FrameworkCase>
<ComponentReference component="PlaybackRateButton" />
@@ -0,0 +1,47 @@
---
title: selectPlaybackRate
description: Select the playback rate state slice from the player store
---
import UtilReference from "@/components/docs/api-reference/UtilReference.astro";
import DocsLink from "@/components/docs/DocsLink.astro";
import FrameworkCase from "@/components/docs/FrameworkCase.astro";
<FrameworkCase frameworks={["react"]}>
Pass `selectPlaybackRate` to <DocsLink slug="reference/use-player">`usePlayer`</DocsLink> to subscribe to playback rate state. Returns `undefined` if the playback rate feature is not configured.
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
Pass `selectPlaybackRate` to <DocsLink slug="reference/player-controller">`PlayerController`</DocsLink> to subscribe to playback rate state. Returns `undefined` if the playback rate feature is not configured.
</FrameworkCase>
The returned state includes `playbackRate`, `playbackRates`, and the `setPlaybackRate` action.
<FrameworkCase frameworks={["react"]}>
```tsx
import { usePlayer } from '@videojs/react';
import { selectPlaybackRate } from '@videojs/core/dom';
function RateDisplay() {
const rate = usePlayer(selectPlaybackRate);
if (!rate) return null;
return <span>{rate.playbackRate}x</span>;
}
```
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
```ts
import { createPlayer, features, MediaElement } from '@videojs/html';
import { selectPlaybackRate } from '@videojs/core/dom';
const { PlayerController, context } = createPlayer({ features: features.video });
class RateDisplay extends MediaElement {
#rate = new PlayerController(this, context, selectPlaybackRate);
}
```
</FrameworkCase>
<UtilReference util="selectPlaybackRate" />
+2
View File
@@ -36,6 +36,7 @@ export const sidebar: Sidebar = [
{ slug: 'reference/mute-button' },
{ slug: 'reference/pip-button' },
{ slug: 'reference/play-button' },
{ slug: 'reference/playback-rate-button' },
{ slug: 'reference/poster' },
{ slug: 'reference/seek-button' },
{ slug: 'reference/time' },
@@ -92,6 +93,7 @@ export const sidebar: Sidebar = [
{ slug: 'reference/select-fullscreen' },
{ slug: 'reference/select-pip', sidebarLabel: 'selectPiP' },
{ slug: 'reference/select-playback' },
{ slug: 'reference/select-playback-rate' },
{ slug: 'reference/select-source' },
{ slug: 'reference/select-time' },
{ slug: 'reference/select-volume' },