mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): add PlaybackRateButton to core, html, and react (#642)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
+142
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user