Skip to content

Commit e726e5b

Browse files
committed
feat: add playback rate menu
1 parent e3c1b28 commit e726e5b

138 files changed

Lines changed: 3759 additions & 387 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/design/ui/menus.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ Button that navigates back to the parent view. Placed at the top of a submenu `C
244244
**ARIA (automatic):** `aria-label` from `label` prop.
245245

246246
**Behavior:**
247+
247248
- Click pops the navigation stack.
248249
- `ArrowLeft` anywhere in the submenu also pops (handled by Content).
249250
- After pop, focus returns to the `Trigger` that navigated forward.
@@ -503,12 +504,7 @@ media-menu {
503504
}
504505

505506
/* Menu open/close — fade + slight scale */
506-
@starting-style {
507-
media-menu[data-open] {
508-
opacity: 0;
509-
transform: scale(0.97);
510-
}
511-
}
507+
media-menu[data-starting-style],
512508
media-menu[data-ending-style] {
513509
opacity: 0;
514510
transform: scale(0.97);

packages/core/src/core/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export * from './ui/play-button/play-button-core';
3838
export * from './ui/play-button/play-button-data-attrs';
3939
export * from './ui/playback-rate-button/playback-rate-button-core';
4040
export * from './ui/playback-rate-button/playback-rate-button-data-attrs';
41+
export * from './ui/playback-rate-menu/playback-rate-menu-core';
42+
export * from './ui/playback-rate-menu/playback-rate-menu-data-attrs';
4143
export * from './ui/popover/popover-core';
4244
export * from './ui/popover/popover-css-vars';
4345
export * from './ui/popover/popover-data-attrs';
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { createState } from '@videojs/store';
2+
import { defaults } from '@videojs/utils/object';
3+
import { isFunction, isUndefined } from '@videojs/utils/predicate';
4+
import type { NonNullableObject } from '@videojs/utils/types';
5+
6+
import type { MediaPlaybackRateState } from '../../media/state';
7+
import type { ButtonState } from '../types';
8+
9+
export interface PlaybackRateMenuProps {
10+
/** Custom label for the menu trigger. */
11+
label?: string | ((state: PlaybackRateMenuState) => string) | undefined;
12+
/** Custom formatter for visible playback rate labels. */
13+
formatRate?: ((rate: number) => string) | undefined;
14+
/** Whether playback rate selection is disabled. */
15+
disabled?: boolean | undefined;
16+
}
17+
18+
export interface PlaybackRateMenuState extends ButtonState {
19+
rate: number;
20+
rates: readonly number[];
21+
disabled: boolean;
22+
}
23+
24+
function formatPlaybackRate(rate: number): string {
25+
return `${rate}×`;
26+
}
27+
28+
export class PlaybackRateMenuCore {
29+
static readonly defaultProps: NonNullableObject<PlaybackRateMenuProps> = {
30+
label: '',
31+
formatRate: formatPlaybackRate,
32+
disabled: false,
33+
};
34+
35+
readonly state = createState<PlaybackRateMenuState>({
36+
rate: 1,
37+
rates: [],
38+
disabled: false,
39+
label: '',
40+
});
41+
42+
#props = { ...PlaybackRateMenuCore.defaultProps };
43+
#media: MediaPlaybackRateState | null = null;
44+
45+
constructor(props?: PlaybackRateMenuProps) {
46+
if (props) this.setProps(props);
47+
}
48+
49+
setProps(props: PlaybackRateMenuProps): void {
50+
this.#props = defaults(props, PlaybackRateMenuCore.defaultProps);
51+
}
52+
53+
getLabel(state: PlaybackRateMenuState): string {
54+
const { label } = this.#props;
55+
56+
if (isFunction(label)) {
57+
const customLabel = label(state);
58+
if (customLabel) return customLabel;
59+
} else if (label) {
60+
return label;
61+
}
62+
63+
return `Playback rate ${state.rate}`;
64+
}
65+
66+
getRateLabel(rate: number): string {
67+
return this.#props.formatRate(rate);
68+
}
69+
70+
getRateValue(rate: number): string {
71+
return String(rate);
72+
}
73+
74+
getAttrs(state: PlaybackRateMenuState) {
75+
return {
76+
'aria-label': this.getLabel(state),
77+
'aria-disabled': state.disabled ? 'true' : undefined,
78+
};
79+
}
80+
81+
setMedia(media: MediaPlaybackRateState): void {
82+
this.#media = media;
83+
}
84+
85+
getState(): PlaybackRateMenuState {
86+
const media = this.#media!;
87+
88+
this.state.patch({
89+
rate: media.playbackRate,
90+
rates: media.playbackRates,
91+
disabled: this.#props.disabled || media.playbackRates.length === 0,
92+
});
93+
this.state.patch({ label: this.getLabel(this.state.current) });
94+
95+
return this.state.current;
96+
}
97+
98+
select(media: MediaPlaybackRateState, rate: number): void {
99+
if (this.#props.disabled) return;
100+
if (!media.playbackRates.includes(rate)) return;
101+
102+
media.setPlaybackRate(rate);
103+
}
104+
105+
selectValue(media: MediaPlaybackRateState, value: string): void {
106+
const rate = media.playbackRates.find((candidate) => this.getRateValue(candidate) === value);
107+
if (isUndefined(rate)) return;
108+
109+
this.select(media, rate);
110+
}
111+
}
112+
113+
export namespace PlaybackRateMenuCore {
114+
export type Props = PlaybackRateMenuProps;
115+
export type State = PlaybackRateMenuState;
116+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { StateAttrMap } from '../types';
2+
import type { PlaybackRateMenuState } from './playback-rate-menu-core';
3+
4+
export const PlaybackRateMenuDataAttrs = {
5+
/** Current playback rate. */
6+
rate: 'data-rate',
7+
/** Present when playback rate selection is disabled. */
8+
disabled: 'data-disabled',
9+
} as const satisfies StateAttrMap<PlaybackRateMenuState>;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import type { MediaPlaybackRateState } from '../../../media/state';
4+
import type { PlaybackRateMenuState } from '../playback-rate-menu-core';
5+
import { PlaybackRateMenuCore } from '../playback-rate-menu-core';
6+
7+
function createMediaState(overrides: Partial<MediaPlaybackRateState> = {}): MediaPlaybackRateState {
8+
return {
9+
playbackRates: [0.5, 1, 1.5, 2],
10+
playbackRate: 1,
11+
setPlaybackRate: vi.fn(),
12+
...overrides,
13+
};
14+
}
15+
16+
function createState(overrides: Partial<PlaybackRateMenuState> = {}): PlaybackRateMenuState {
17+
return {
18+
rate: 1,
19+
rates: [0.5, 1, 1.5, 2],
20+
disabled: false,
21+
label: '',
22+
...overrides,
23+
};
24+
}
25+
26+
describe('PlaybackRateMenuCore', () => {
27+
describe('getState', () => {
28+
it('projects playbackRate and playbackRates', () => {
29+
const core = new PlaybackRateMenuCore();
30+
const media = createMediaState({ playbackRate: 1.5, playbackRates: [1, 1.5] });
31+
core.setMedia(media);
32+
const state = core.getState();
33+
34+
expect(state.rate).toBe(1.5);
35+
expect(state.rates).toEqual([1, 1.5]);
36+
});
37+
38+
it('marks state disabled when no rates are available', () => {
39+
const core = new PlaybackRateMenuCore();
40+
const media = createMediaState({ playbackRates: [] });
41+
core.setMedia(media);
42+
43+
expect(core.getState().disabled).toBe(true);
44+
});
45+
});
46+
47+
describe('getLabel', () => {
48+
it('returns default label with rate', () => {
49+
const core = new PlaybackRateMenuCore();
50+
expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate 1.5');
51+
});
52+
53+
it('returns custom string label', () => {
54+
const core = new PlaybackRateMenuCore({ label: 'Speed' });
55+
expect(core.getLabel(createState())).toBe('Speed');
56+
});
57+
58+
it('returns custom function label', () => {
59+
const core = new PlaybackRateMenuCore({
60+
label: (state) => `${state.rate}× speed`,
61+
});
62+
expect(core.getLabel(createState({ rate: 2 }))).toBe('2× speed');
63+
});
64+
});
65+
66+
describe('getRateLabel', () => {
67+
it('formats rate labels by default', () => {
68+
const core = new PlaybackRateMenuCore();
69+
expect(core.getRateLabel(1.5)).toBe('1.5×');
70+
});
71+
72+
it('uses a custom formatter', () => {
73+
const core = new PlaybackRateMenuCore({
74+
formatRate: (rate) => (rate === 1 ? 'Normal' : `${rate}×`),
75+
});
76+
77+
expect(core.getRateLabel(1)).toBe('Normal');
78+
});
79+
});
80+
81+
describe('getAttrs', () => {
82+
it('returns aria-label', () => {
83+
const core = new PlaybackRateMenuCore();
84+
const attrs = core.getAttrs(createState({ rate: 1.5 }));
85+
expect(attrs['aria-label']).toBe('Playback rate 1.5');
86+
});
87+
88+
it('sets aria-disabled when disabled', () => {
89+
const core = new PlaybackRateMenuCore();
90+
const attrs = core.getAttrs(createState({ disabled: true }));
91+
expect(attrs['aria-disabled']).toBe('true');
92+
});
93+
});
94+
95+
describe('select', () => {
96+
it('sets a rate from the available list', () => {
97+
const core = new PlaybackRateMenuCore();
98+
const media = createMediaState();
99+
core.select(media, 1.5);
100+
expect(media.setPlaybackRate).toHaveBeenCalledWith(1.5);
101+
});
102+
103+
it('does nothing when disabled', () => {
104+
const core = new PlaybackRateMenuCore({ disabled: true });
105+
const media = createMediaState();
106+
core.select(media, 1.5);
107+
expect(media.setPlaybackRate).not.toHaveBeenCalled();
108+
});
109+
110+
it('does nothing for unavailable rates', () => {
111+
const core = new PlaybackRateMenuCore();
112+
const media = createMediaState();
113+
core.select(media, 3);
114+
expect(media.setPlaybackRate).not.toHaveBeenCalled();
115+
});
116+
});
117+
118+
describe('selectValue', () => {
119+
it('sets the rate matching a menu value', () => {
120+
const core = new PlaybackRateMenuCore();
121+
const media = createMediaState();
122+
core.selectValue(media, '2');
123+
expect(media.setPlaybackRate).toHaveBeenCalledWith(2);
124+
});
125+
126+
it('does nothing for an unknown menu value', () => {
127+
const core = new PlaybackRateMenuCore();
128+
const media = createMediaState();
129+
core.selectValue(media, '3');
130+
expect(media.setPlaybackRate).not.toHaveBeenCalled();
131+
});
132+
});
133+
});

packages/core/src/core/ui/popover/popover-css-vars.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export const PopoverCSSVars = {
33
sideOffset: '--media-popover-side-offset',
44
/** Distance between the popup and the trigger along the alignment axis. */
55
alignOffset: '--media-popover-align-offset',
6+
/** Minimum distance between the popup and the positioning boundary. */
7+
boundaryOffset: '--media-popover-boundary-offset',
68
/** The anchor element's width. */
79
anchorWidth: '--media-popover-anchor-width',
810
/** The anchor element's height. */

packages/core/src/core/ui/tooltip/tooltip-css-vars.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export const TooltipCSSVars = {
33
sideOffset: '--media-tooltip-side-offset',
44
/** Distance between the popup and the trigger along the alignment axis. */
55
alignOffset: '--media-tooltip-align-offset',
6+
/** Minimum distance between the popup and the positioning boundary. */
7+
boundaryOffset: '--media-tooltip-boundary-offset',
68
/** The anchor element's width. */
79
anchorWidth: '--media-tooltip-anchor-width',
810
/** The anchor element's height. */

packages/core/src/dom/gesture/tests/gesture.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,22 @@ describe('interactive child filtering', () => {
431431
expect(handler).not.toHaveBeenCalled();
432432
});
433433

434+
it('does not fire when event originates from a child with role="menuitemradio"', () => {
435+
const container = setup();
436+
const item = document.createElement('div');
437+
item.setAttribute('role', 'menuitemradio');
438+
container.appendChild(item);
439+
440+
const handler = vi.fn();
441+
createTapGesture(container, handler);
442+
443+
pointerDown(item);
444+
vi.advanceTimersByTime(50);
445+
pointerUp(item, { pointerType: 'mouse', clientX: 150 });
446+
447+
expect(handler).not.toHaveBeenCalled();
448+
});
449+
434450
it('does not fire when event originates from a nested child inside an interactive element', () => {
435451
const container = setup();
436452
const button = document.createElement('button');

packages/core/src/dom/hotkey/coordinator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ export class HotkeyCoordinator {
135135
// Let interactive elements handle their own activation keys.
136136
if (isInteractiveActivation(event)) return;
137137

138+
if (event.defaultPrevented) return;
139+
138140
const editable = isEditableTarget(event);
139141

140142
for (const binding of this.#bindings) {

packages/core/src/dom/hotkey/tests/hotkey.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,4 +286,18 @@ describe('createHotkey', () => {
286286

287287
cleanup();
288288
});
289+
290+
it('does not fire when the event has already been handled', () => {
291+
const el = setup();
292+
const onActivate = vi.fn();
293+
294+
el.addEventListener('keydown', (event) => event.preventDefault(), { capture: true });
295+
const cleanup = createHotkey(el, { keys: 'ArrowRight', onActivate });
296+
297+
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }));
298+
299+
expect(onActivate).not.toHaveBeenCalled();
300+
301+
cleanup();
302+
});
289303
});

0 commit comments

Comments
 (0)