feat(packages): add UI support for gestures and hotkeys (#1388)

Co-authored-by: Rahim <rahim.alwer@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Potts
2026-05-05 10:34:12 +10:00
committed by GitHub
co-authored by Rahim Claude Opus 4.6
parent 6c81f2d190
commit 0620814a67
187 changed files with 5665 additions and 405 deletions
+6 -31
View File
@@ -1,7 +1,6 @@
import { isFunction, isUndefined } from '@videojs/utils/predicate';
import { isFunction } from '@videojs/utils/predicate';
import type { AnyPlayerStore } from '../media/types';
import { selectPlaybackRate, selectTime, selectVolume } from '../store/selectors';
import { MEDIA_INPUT_ACTION_OVERRIDES } from '../media-actions';
export type GestureActionName =
| 'togglePaused'
@@ -25,37 +24,13 @@ export type GestureActionResolver = (context: GestureActionContext) => void;
/** Actions that need custom logic beyond `store.state[action]()`. */
const GESTURE_ACTION_OVERRIDES: Partial<Record<GestureActionName, GestureActionResolver>> = {
seekStep({ store, value }) {
if (isUndefined(value)) return;
const time = selectTime(store.state);
if (!time) return;
time.seek(time.currentTime + value);
},
seekStep: MEDIA_INPUT_ACTION_OVERRIDES.seekStep,
volumeStep({ store, value }) {
if (isUndefined(value)) return;
const vol = selectVolume(store.state);
if (!vol) return;
vol.setVolume(vol.volume + value);
},
volumeStep: MEDIA_INPUT_ACTION_OVERRIDES.volumeStep,
speedUp({ store }) {
const rate = selectPlaybackRate(store.state);
if (!rate) return;
const { playbackRates, playbackRate } = rate;
const idx = playbackRates.indexOf(playbackRate);
const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1;
rate.setPlaybackRate(playbackRates[next]!);
},
speedUp: MEDIA_INPUT_ACTION_OVERRIDES.speedUp,
speedDown({ store }) {
const rate = selectPlaybackRate(store.state);
if (!rate) return;
const { playbackRates, playbackRate } = rate;
const idx = playbackRates.indexOf(playbackRate);
const next = idx <= 0 ? playbackRates.length - 1 : idx - 1;
rate.setPlaybackRate(playbackRates[next]!);
},
speedDown: MEDIA_INPUT_ACTION_OVERRIDES.speedDown,
};
export function resolveGestureAction(name: GestureActionName | (string & {})): GestureActionResolver | undefined {
+42 -4
View File
@@ -1,6 +1,13 @@
import { isInteractiveTarget, listen } from '@videojs/utils/dom';
import type { GestureBinding, GestureMatchResult, GestureRecognizer, GestureRegion, GestureType } from './gesture';
import type {
GestureActivateEvent,
GestureBinding,
GestureMatchResult,
GestureRecognizer,
GestureRegion,
GestureType,
} from './gesture';
import { resolveRegion } from './region';
const TAP_THRESHOLD = 250;
@@ -10,6 +17,7 @@ export class GestureCoordinator {
#bindings: GestureBinding[] = [];
#recognizers = new Set<GestureRecognizer>();
#disconnect: AbortController | null = null;
#subscribers = new Set<(event: GestureActivateEvent) => void>();
constructor(target: HTMLElement) {
this.#target = target;
@@ -19,9 +27,39 @@ export class GestureCoordinator {
return this.#bindings;
}
subscribe(callback: (event: GestureActivateEvent) => void): () => void {
this.#subscribers.add(callback);
return () => this.#subscribers.delete(callback);
}
add(binding: GestureBinding): () => void {
this.#bindings.push(binding);
this.#recognizers.add(binding.recognizer);
const wrapped: GestureBinding = {
...binding,
onActivate: (event) => {
if (this.#subscribers.size > 0) {
const activateEvent: GestureActivateEvent = {
type: binding.type,
source: 'gesture',
action: binding.action,
value: binding.value,
region: binding.region,
pointer: binding.pointer,
event,
};
for (const cb of this.#subscribers) {
try {
cb(activateEvent);
} catch (error) {
if (__DEV__) console.warn('[vjs-gesture] subscribe callback threw:', error);
}
}
}
binding.onActivate(event);
},
};
this.#bindings.push(wrapped);
this.#recognizers.add(wrapped.recognizer);
this.#connect();
let removed = false;
@@ -29,7 +67,7 @@ export class GestureCoordinator {
if (removed) return;
removed = true;
const idx = this.#bindings.indexOf(binding);
const idx = this.#bindings.indexOf(wrapped);
if (idx !== -1) this.#bindings.splice(idx, 1);
this.#maybeDisconnect();
@@ -36,6 +36,7 @@ export function createTapGesture(
region: options?.region,
disabled: options?.disabled,
action: options?.action,
value: options?.value,
});
}
@@ -62,5 +63,6 @@ export function createDoubleTapGesture(
region: options?.region,
disabled: options?.disabled,
action: options?.action,
value: options?.value,
});
}
+12
View File
@@ -9,6 +9,7 @@ export interface GestureOptions {
region?: GestureRegion | undefined;
disabled?: boolean | undefined;
action?: string | undefined;
value?: number | undefined;
}
export interface GestureBinding {
@@ -19,6 +20,17 @@ export interface GestureBinding {
region?: GestureRegion | undefined;
disabled?: boolean | undefined;
action?: string | undefined;
value?: number | undefined;
}
export interface GestureActivateEvent {
type: GestureType;
source: 'gesture';
action?: string | undefined;
value?: number | undefined;
region?: GestureRegion | undefined;
pointer?: GesturePointerType | undefined;
event: PointerEvent;
}
export interface GestureRecognizer {
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getGestureCoordinator } from '../coordinator';
import { createDoubleTapGesture, createTapGesture } from '../create-tap-gesture';
function setup() {
const container = document.createElement('div');
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 300,
width: 300,
top: 0,
bottom: 200,
height: 200,
x: 0,
y: 0,
toJSON: () => {},
});
return container;
}
describe('GestureCoordinator.subscribe', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('fires subscriber on tap', () => {
const container = setup();
const subscriber = vi.fn();
getGestureCoordinator(container).subscribe(subscriber);
createTapGesture(container, vi.fn(), { action: 'togglePaused' });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(subscriber).toHaveBeenCalledOnce();
expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ type: 'tap', action: 'togglePaused' }));
});
it('fires subscriber on doubletap', () => {
const container = setup();
const subscriber = vi.fn();
getGestureCoordinator(container).subscribe(subscriber);
createDoubleTapGesture(container, vi.fn(), { action: 'seekStep', value: 10, region: 'right' });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 250 });
vi.advanceTimersByTime(100);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 250 });
expect(subscriber).toHaveBeenCalledOnce();
expect(subscriber).toHaveBeenCalledWith(
expect.objectContaining({ type: 'doubletap', action: 'seekStep', value: 10, region: 'right' })
);
});
it('includes pointer type in subscriber event', () => {
const container = setup();
const subscriber = vi.fn();
getGestureCoordinator(container).subscribe(subscriber);
createTapGesture(container, vi.fn(), { pointer: 'touch' });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'touch', clientX: 150 });
expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ pointer: 'touch' }));
});
it('returns unsubscribe function that stops callbacks', () => {
const container = setup();
const subscriber = vi.fn();
const unsubscribe = getGestureCoordinator(container).subscribe(subscriber);
createTapGesture(container, vi.fn());
unsubscribe();
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(subscriber).not.toHaveBeenCalled();
});
it('still invokes binding onActivate when a subscriber throws', () => {
const container = setup();
const bindingActivate = vi.fn();
getGestureCoordinator(container).subscribe(() => {
throw new Error('subscriber boom');
});
createTapGesture(container, bindingActivate, { action: 'togglePaused' });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(bindingActivate).toHaveBeenCalledOnce();
});
it('does not fire subscriber when gesture binding does not match', () => {
const container = setup();
const subscriber = vi.fn();
getGestureCoordinator(container).subscribe(subscriber);
createTapGesture(container, vi.fn(), { pointer: 'touch' });
pointerDown(container);
vi.advanceTimersByTime(50);
// Fire with mouse, but binding is touch-only.
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(subscriber).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function pointerDown(target: HTMLElement, init: { button?: number } = {}): void {
const event = new Event('pointerdown', { bubbles: true });
Object.defineProperty(event, 'button', { value: init.button ?? 0 });
target.dispatchEvent(event);
}
function pointerUp(target: HTMLElement, init: { pointerType: string; clientX: number; button?: number }): void {
const event = new Event('pointerup', { bubbles: true });
Object.defineProperty(event, 'pointerType', { value: init.pointerType });
Object.defineProperty(event, 'clientX', { value: init.clientX });
Object.defineProperty(event, 'button', { value: init.button ?? 0 });
target.dispatchEvent(event);
}
+5 -30
View File
@@ -1,11 +1,10 @@
import { isUndefined } from '@videojs/utils/predicate';
import type { AnyPlayerStore } from '../media/types';
import { MEDIA_INPUT_ACTION_OVERRIDES } from '../media-actions';
import {
selectFullscreen,
selectPiP,
selectPlayback,
selectPlaybackRate,
selectTextTrack,
selectTime,
selectVolume,
@@ -63,37 +62,13 @@ const HOTKEY_ACTIONS: Record<HotkeyActionName, HotkeyActionResolver> = {
pip.pip ? pip.exitPictureInPicture() : pip.requestPictureInPicture();
},
seekStep({ store, value }) {
if (isUndefined(value)) return;
const time = selectTime(store.state);
if (!time) return;
time.seek(time.currentTime + value);
},
seekStep: MEDIA_INPUT_ACTION_OVERRIDES.seekStep,
volumeStep({ store, value }) {
if (isUndefined(value)) return;
const vol = selectVolume(store.state);
if (!vol) return;
vol.setVolume(vol.volume + value);
},
volumeStep: MEDIA_INPUT_ACTION_OVERRIDES.volumeStep,
speedUp({ store }) {
const rate = selectPlaybackRate(store.state);
if (!rate) return;
const { playbackRates, playbackRate } = rate;
const idx = playbackRates.indexOf(playbackRate);
const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1;
rate.setPlaybackRate(playbackRates[next]!);
},
speedUp: MEDIA_INPUT_ACTION_OVERRIDES.speedUp,
speedDown({ store }) {
const rate = selectPlaybackRate(store.state);
if (!rate) return;
const { playbackRates, playbackRate } = rate;
const idx = playbackRates.indexOf(playbackRate);
const next = idx <= 0 ? playbackRates.length - 1 : idx - 1;
rate.setPlaybackRate(playbackRates[next]!);
},
speedDown: MEDIA_INPUT_ACTION_OVERRIDES.speedDown,
seekToPercent({ store, value, key }) {
const time = selectTime(store.state);
@@ -4,6 +4,13 @@ import { toAriaKeyShortcut } from './aria';
import type { HotkeyOptions, ParsedHotkeyBinding } from './hotkey';
import { matchesHotkeyEvent, parseHotkeyPattern } from './hotkey';
export interface HotkeyActivateEvent {
source: 'hotkey';
action?: string | undefined;
value?: number | undefined;
event: KeyboardEvent;
}
interface HotkeyBinding {
parsed: ParsedHotkeyBinding[];
options: HotkeyOptions;
@@ -19,12 +26,18 @@ export class HotkeyCoordinator {
#docDisconnect: AbortController | null = null;
/** Action name → bound keys. Controls query this to set `aria-keyshortcuts`. */
#ariaRegistry = new Map<string, ParsedHotkeyBinding[]>();
#subscribers = new Set<(event: HotkeyActivateEvent) => void>();
#destroyed = false;
constructor(target: HTMLElement) {
this.#target = target;
}
subscribe(callback: (event: HotkeyActivateEvent) => void): () => void {
this.#subscribers.add(callback);
return () => this.#subscribers.delete(callback);
}
add(options: HotkeyOptions): () => void {
const parsed = parseHotkeyPattern(options.keys);
const binding: HotkeyBinding = { parsed, options, id: this.#nextId++ };
@@ -141,6 +154,21 @@ export class HotkeyCoordinator {
// Input safety: single-key shortcuts suppressed in editable fields.
if (editable && p.modifiers.size === 0) continue;
if (this.#subscribers.size > 0) {
const activateEvent: HotkeyActivateEvent = {
source: 'hotkey',
action: options.action,
value: options.value,
event,
};
for (const cb of this.#subscribers) {
try {
cb(activateEvent);
} catch (error) {
if (__DEV__) console.warn('[vjs-hotkey] subscribe callback threw:', error);
}
}
}
event.preventDefault();
options.onActivate(event, p.originalKey);
return;
+6 -3
View File
@@ -20,8 +20,10 @@ export interface HotkeyOptions {
/** Whether `event.repeat` should fire the callback. */
repeatable?: boolean | undefined;
disabled?: boolean | undefined;
/** Action name for the ARIA registry. */
/** Action name for the ARIA registry and subscriber events. */
action?: string | undefined;
/** Numeric magnitude passed to subscriber events (e.g. 10 for `seekStep`). */
value?: number | undefined;
}
const MODIFIER_KEYS = new Set(['shift', 'ctrl', 'alt', 'meta']);
@@ -114,7 +116,8 @@ export function findHotkeyCoordinator(target: HTMLElement): HotkeyCoordinator |
return coordinators.get(target);
}
function getCoordinator(target: HTMLElement): HotkeyCoordinator {
/** Look up or create the hotkey coordinator for a target element. */
export function getHotkeyCoordinator(target: HTMLElement): HotkeyCoordinator {
let coordinator = coordinators.get(target);
if (!coordinator) {
coordinator = new HotkeyCoordinator(target);
@@ -140,6 +143,6 @@ function getCoordinator(target: HTMLElement): HotkeyCoordinator {
* @returns A cleanup function that removes the binding.
*/
export function createHotkey(target: HTMLElement, options: HotkeyOptions): () => void {
const coordinator = getCoordinator(target);
const coordinator = getHotkeyCoordinator(target);
return coordinator.add(options);
}
@@ -325,6 +325,35 @@ describe('HotkeyCoordinator', () => {
});
});
describe('subscribe', () => {
it('still invokes onActivate when a subscriber throws', () => {
const c = setup();
const onActivate = vi.fn();
c.subscribe(() => {
throw new Error('subscriber boom');
});
c.add({ keys: 'k', onActivate });
keydown(container, 'k');
expect(onActivate).toHaveBeenCalledOnce();
});
it('runs subsequent subscribers after one throws', () => {
const c = setup();
const second = vi.fn();
c.subscribe(() => {
throw new Error('first');
});
c.subscribe(second);
c.add({ keys: 'k', onActivate: vi.fn() });
keydown(container, 'k');
expect(second).toHaveBeenCalledOnce();
});
});
describe('ARIA registry', () => {
it('returns undefined for unregistered action', () => {
const c = setup();
+1
View File
@@ -14,6 +14,7 @@ export * from './ui/alert-dialog';
export * from './ui/button';
export * from './ui/dismiss-layer';
export * from './ui/event';
export * from './ui/input-action';
export * from './ui/popover/popover';
export * from './ui/popover/popover-positioning';
export * from './ui/slider';
+47
View File
@@ -0,0 +1,47 @@
import { isUndefined } from '@videojs/utils/predicate';
import type { AnyPlayerStore } from './media/types';
import { selectPlaybackRate, selectTime, selectVolume } from './store/selectors';
export type MediaInputActionName = 'seekStep' | 'volumeStep' | 'speedUp' | 'speedDown';
export interface MediaInputActionContext {
store: AnyPlayerStore;
value?: number | undefined;
}
export type MediaInputActionResolver = (context: MediaInputActionContext) => void;
export const MEDIA_INPUT_ACTION_OVERRIDES: Record<MediaInputActionName, MediaInputActionResolver> = {
seekStep({ store, value }) {
if (isUndefined(value)) return;
const time = selectTime(store.state);
if (!time) return;
time.seek(time.currentTime + value);
},
volumeStep({ store, value }) {
if (isUndefined(value)) return;
const vol = selectVolume(store.state);
if (!vol) return;
vol.setVolume(vol.volume + value);
},
speedUp({ store }) {
const rate = selectPlaybackRate(store.state);
if (!rate) return;
const { playbackRates, playbackRate } = rate;
const idx = playbackRates.indexOf(playbackRate);
const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1;
rate.setPlaybackRate(playbackRates[next]!);
},
speedDown({ store }) {
const rate = selectPlaybackRate(store.state);
if (!rate) return;
const { playbackRates, playbackRate } = rate;
const idx = playbackRates.indexOf(playbackRate);
const next = idx <= 0 ? playbackRates.length - 1 : idx - 1;
rate.setPlaybackRate(playbackRates[next]!);
},
};
+72
View File
@@ -0,0 +1,72 @@
import { IndicatorVisibilityCoordinator } from '../../core/ui/input-feedback/indicator-lifecycle';
import type { InputActionEvent, MediaSnapshot } from '../../core/ui/input-feedback/status';
import { getGestureCoordinator } from '../gesture/coordinator';
import type { GestureActivateEvent } from '../gesture/gesture';
import type { HotkeyActivateEvent } from '../hotkey/coordinator';
import { getHotkeyCoordinator } from '../hotkey/hotkey';
import {
selectFullscreen,
selectPiP,
selectPlayback,
selectTextTrack,
selectTime,
selectVolume,
} from '../store/selectors';
export type CoordinatorEvent = GestureActivateEvent | HotkeyActivateEvent;
export interface MediaSnapshotStore {
readonly state: object;
}
export function toInputActionEvent(event: CoordinatorEvent): InputActionEvent {
return {
action: event.action,
value: event.value,
source: event.source,
key: 'key' in event.event ? event.event.key : undefined,
};
}
export function getMediaSnapshot(store: MediaSnapshotStore | undefined): MediaSnapshot {
if (!store) return {};
const state = store.state;
const time = selectTime(state);
return {
paused: selectPlayback(state)?.paused,
volume: selectVolume(state)?.volume,
muted: selectVolume(state)?.muted,
fullscreen: selectFullscreen(state)?.fullscreen,
subtitlesShowing: selectTextTrack(state)?.subtitlesShowing,
pip: selectPiP(state)?.pip,
currentTime: time?.currentTime,
duration: time?.duration,
};
}
export function subscribeToInputActions(
container: HTMLElement,
callback: (event: InputActionEvent) => void
): () => void {
const handleEvent = (event: CoordinatorEvent) => callback(toInputActionEvent(event));
const gestureUnsubscribe = getGestureCoordinator(container).subscribe(handleEvent);
const hotkeyUnsubscribe = getHotkeyCoordinator(container).subscribe(handleEvent);
return () => {
gestureUnsubscribe();
hotkeyUnsubscribe();
};
}
const indicatorVisibilityCoordinators = new WeakMap<HTMLElement, IndicatorVisibilityCoordinator>();
export function getIndicatorVisibilityCoordinator(container: HTMLElement): IndicatorVisibilityCoordinator {
let coordinator = indicatorVisibilityCoordinators.get(container);
if (!coordinator) {
coordinator = new IndicatorVisibilityCoordinator();
indicatorVisibilityCoordinators.set(container, coordinator);
}
return coordinator;
}
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest';
import {
getIndicatorVisibilityCoordinator,
getMediaSnapshot,
type MediaSnapshotStore,
toInputActionEvent,
} from '../input-action';
function mockStore(state: Record<string, unknown>): MediaSnapshotStore {
return { state };
}
describe('input-action', () => {
it('converts coordinator events to input action events', () => {
expect(
toInputActionEvent({
source: 'hotkey',
action: 'togglePaused',
value: 1,
event: new KeyboardEvent('keydown', { key: 'k' }),
})
).toEqual({
source: 'hotkey',
action: 'togglePaused',
value: 1,
key: 'k',
});
});
it('derives media snapshots from player store selectors', () => {
expect(
getMediaSnapshot(
mockStore({
chaptersCues: [],
paused: true,
volume: 0.5,
muted: false,
fullscreen: true,
subtitlesShowing: true,
pip: false,
currentTime: 30,
duration: 120,
})
)
).toEqual({
paused: true,
volume: 0.5,
muted: false,
fullscreen: true,
subtitlesShowing: true,
pip: false,
currentTime: 30,
duration: 120,
});
});
it('shares a visibility coordinator per container', () => {
const container = document.createElement('div');
const first = { close: vi.fn() };
const second = { close: vi.fn() };
const coordinator = getIndicatorVisibilityCoordinator(container);
coordinator.register(first);
coordinator.register(second);
coordinator.show(second);
expect(getIndicatorVisibilityCoordinator(container)).toBe(coordinator);
expect(first.close).toHaveBeenCalledOnce();
expect(second.close).not.toHaveBeenCalled();
});
});