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
+10
View File
@@ -14,6 +14,16 @@ export * from './ui/error-dialog/error-dialog-core';
export * from './ui/error-dialog/error-dialog-data-attrs';
export * from './ui/fullscreen-button/fullscreen-button-core';
export * from './ui/fullscreen-button/fullscreen-button-data-attrs';
export * from './ui/input-feedback/indicator-lifecycle';
export * from './ui/input-feedback/seek-indicator-core';
export * from './ui/input-feedback/seek-indicator-data-attrs';
export * from './ui/input-feedback/status';
export * from './ui/input-feedback/status-announcer-core';
export * from './ui/input-feedback/status-indicator-core';
export * from './ui/input-feedback/status-indicator-data-attrs';
export * from './ui/input-feedback/volume-indicator-core';
export * from './ui/input-feedback/volume-indicator-css-vars';
export * from './ui/input-feedback/volume-indicator-data-attrs';
export * from './ui/live-button/live-button-core';
export * from './ui/live-button/live-button-data-attrs';
export * from './ui/mute-button/mute-button-core';
@@ -0,0 +1,93 @@
import type { TransitionFlags, TransitionState } from '../transition';
import { getTransitionFlags } from '../transition';
export const INDICATOR_CLOSE_DELAY = 800;
export interface IndicatorCoreProps {
/** Delay in milliseconds before the indicator closes. */
closeDelay?: number | undefined;
}
export interface IndicatorLifecycleState extends TransitionFlags {
open: boolean;
generation: number;
}
export class IndicatorCloseController {
#timer: ReturnType<typeof setTimeout> | null = null;
#close: () => void;
#getDelay: () => number;
constructor(close: () => void, getDelay: () => number) {
this.#close = close;
this.#getDelay = getDelay;
}
arm(): void {
this.clear();
this.#timer = setTimeout(() => {
this.#timer = null;
this.#close();
}, this.#getDelay());
}
clear(): void {
if (this.#timer === null) return;
clearTimeout(this.#timer);
this.#timer = null;
}
close(): void {
this.clear();
this.#close();
}
destroy(): void {
this.clear();
}
}
export interface IndicatorVisibilityHandle {
close(): void;
}
export class IndicatorVisibilityCoordinator<Handle extends IndicatorVisibilityHandle = IndicatorVisibilityHandle> {
#handles = new Set<Handle>();
register(handle: Handle): () => void {
this.#handles.add(handle);
return () => this.#handles.delete(handle);
}
show(handle: Handle): void {
for (const nextHandle of this.#handles) {
if (nextHandle !== handle) nextHandle.close();
}
}
}
export function getIndicatorCloseDelay(props: IndicatorCoreProps): number {
return props.closeDelay ?? INDICATOR_CLOSE_DELAY;
}
export function isIndicatorPresent(
current: Pick<IndicatorLifecycleState, 'open'>,
transition: Pick<TransitionState, 'active'>
): boolean {
return current.open || transition.active;
}
export function getRenderedIndicatorState<State extends IndicatorLifecycleState>(
current: State,
snapshot: State,
transition: TransitionState
): State {
const payload = current.open ? current : snapshot;
return {
...payload,
open: current.open && transition.active,
generation: current.open ? current.generation : payload.generation,
...getTransitionFlags(transition.status),
};
}
@@ -0,0 +1,112 @@
import { createState } from '@videojs/store';
import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle';
import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle';
import {
formatCurrentTime,
getSeekDirection,
type IndicatorDirection,
type InputActionEvent,
isSeekIndicatorAction,
type MediaSnapshot,
} from './status';
export interface SeekIndicatorProps extends IndicatorCoreProps {}
export interface SeekIndicatorState extends IndicatorLifecycleState {
direction: IndicatorDirection | null;
count: number;
seekTotal: number;
value: string | null;
currentTime: string;
}
const INITIAL_STATE: SeekIndicatorState = {
open: false,
generation: 0,
direction: null,
count: 0,
seekTotal: 0,
value: null,
currentTime: '0:00',
transitionStarting: false,
transitionEnding: false,
};
export class SeekIndicatorCore {
readonly state = createState<SeekIndicatorState>({ ...INITIAL_STATE });
#props: SeekIndicatorProps = {};
#originTime: number | null = null;
#close = new IndicatorCloseController(
() => {
this.#originTime = null;
this.state.patch({
open: false,
direction: null,
count: 0,
seekTotal: 0,
value: null,
});
},
() => getIndicatorCloseDelay(this.#props)
);
setProps(props: SeekIndicatorProps): void {
this.#props = props;
}
destroy(): void {
this.#close.destroy();
}
close(): void {
this.#close.close();
}
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean {
if (!isSeekIndicatorAction(event.action)) return false;
const current = this.state.current;
const direction = getSeekDirection(event, snapshot);
const rapidRepeat = current.open && event.action === 'seekStep' && current.direction === direction;
if (!rapidRepeat) {
this.#originTime = snapshot.currentTime ?? null;
}
const value = this.#getEffectiveSeekValue(event, snapshot, rapidRepeat);
const seekTotal = rapidRepeat ? current.seekTotal + Math.abs(value) : Math.abs(value);
this.state.patch({
open: true,
generation: current.generation + 1,
direction,
count: rapidRepeat ? current.count + 1 : 1,
seekTotal,
value: event.action === 'seekStep' && seekTotal > 0 ? `${seekTotal}s` : null,
currentTime: formatCurrentTime(snapshot),
});
this.#close.arm();
return true;
}
#getEffectiveSeekValue(event: InputActionEvent, snapshot: MediaSnapshot, rapidRepeat: boolean): number {
if (event.action !== 'seekStep' || event.value === undefined) return 0;
if (!rapidRepeat || this.#originTime === null) return event.value;
const originTime = this.#originTime;
const duration = snapshot.duration ?? Infinity;
const currentTotal = this.state.current.seekTotal;
const step = Math.abs(event.value);
const room =
event.value < 0 ? Math.max(0, originTime - currentTotal) : Math.max(0, duration - originTime - currentTotal);
return room >= step ? event.value : 0;
}
}
export namespace SeekIndicatorCore {
export type Props = SeekIndicatorProps;
export type State = SeekIndicatorState;
}
@@ -0,0 +1,9 @@
import type { StateAttrMap } from '../types';
import type { SeekIndicatorState } from './seek-indicator-core';
export const SeekIndicatorDataAttrs = {
open: 'data-open',
direction: 'data-direction',
transitionStarting: 'data-starting-style',
transitionEnding: 'data-ending-style',
} as const satisfies StateAttrMap<SeekIndicatorState>;
@@ -0,0 +1,54 @@
import { createState } from '@videojs/store';
import type { IndicatorCoreProps } from './indicator-lifecycle';
import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle';
import {
DEFAULT_INPUT_INDICATOR_LABELS,
deriveAnnouncerLabel,
type InputActionEvent,
type InputIndicatorLabels,
type MediaSnapshot,
} from './status';
export interface StatusAnnouncerProps extends IndicatorCoreProps {
labels?: Partial<InputIndicatorLabels> | undefined;
}
export interface StatusAnnouncerState {
label: string | null;
}
export class StatusAnnouncerCore {
readonly state = createState<StatusAnnouncerState>({ label: null });
#props: StatusAnnouncerProps = {};
#close = new IndicatorCloseController(
() => this.state.patch({ label: null }),
() => getIndicatorCloseDelay(this.#props)
);
setProps(props: StatusAnnouncerProps): void {
this.#props = props;
}
destroy(): void {
this.#close.destroy();
}
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean {
const label = deriveAnnouncerLabel(event, snapshot, {
...DEFAULT_INPUT_INDICATOR_LABELS,
...this.#props.labels,
});
if (!label) return false;
this.state.patch({ label });
this.#close.arm();
return true;
}
}
export namespace StatusAnnouncerCore {
export type Props = StatusAnnouncerProps;
export type State = StatusAnnouncerState;
}
@@ -0,0 +1,85 @@
import { createState } from '@videojs/store';
import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle';
import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle';
import {
DEFAULT_INPUT_INDICATOR_LABELS,
deriveStatus,
type InputAction,
type InputActionEvent,
type InputIndicatorLabels,
isInputActionIncluded,
type MediaSnapshot,
} from './status';
export interface StatusIndicatorProps extends IndicatorCoreProps {
actions?: readonly InputAction[] | undefined;
labels?: Partial<InputIndicatorLabels> | undefined;
}
export interface StatusIndicatorState extends IndicatorLifecycleState {
status: ReturnType<typeof deriveStatus> extends infer Details
? Details extends { status: infer Status }
? Status | null
: never
: never;
label: string | null;
value: string | null;
}
const INITIAL_STATE: StatusIndicatorState = {
open: false,
generation: 0,
status: null,
label: null,
value: null,
transitionStarting: false,
transitionEnding: false,
};
export class StatusIndicatorCore {
readonly state = createState<StatusIndicatorState>({ ...INITIAL_STATE });
#props: StatusIndicatorProps = {};
#close = new IndicatorCloseController(
() => this.state.patch({ open: false, status: null, label: null, value: null }),
() => getIndicatorCloseDelay(this.#props)
);
setProps(props: StatusIndicatorProps): void {
this.#props = props;
}
destroy(): void {
this.#close.destroy();
}
close(): void {
this.#close.close();
}
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean {
if (!isInputActionIncluded(event.action, this.#props.actions)) return false;
const details = deriveStatus(event, snapshot, {
...DEFAULT_INPUT_INDICATOR_LABELS,
...this.#props.labels,
});
if (!details) return false;
this.state.patch({
open: true,
generation: this.state.current.generation + 1,
status: details.status,
label: details.label,
value: details.value,
});
this.#close.arm();
return true;
}
}
export namespace StatusIndicatorCore {
export type Props = StatusIndicatorProps;
export type State = StatusIndicatorState;
}
@@ -0,0 +1,9 @@
import type { StateAttrMap } from '../types';
import type { StatusIndicatorState } from './status-indicator-core';
export const StatusIndicatorDataAttrs = {
open: 'data-open',
status: 'data-status',
transitionStarting: 'data-starting-style',
transitionEnding: 'data-ending-style',
} as const satisfies StateAttrMap<StatusIndicatorState>;
@@ -0,0 +1,267 @@
import { clamp } from '@videojs/utils/number';
import { formatTime } from '@videojs/utils/time';
export type InputActionSource = 'gesture' | 'hotkey';
export type InputAction =
| 'togglePaused'
| 'toggleMuted'
| 'toggleFullscreen'
| 'toggleSubtitles'
| 'togglePictureInPicture'
| 'toggleControls'
| 'seekStep'
| 'seekToPercent'
| 'volumeStep'
| 'speedUp'
| 'speedDown'
| (string & {});
export type IndicatorDirection = 'forward' | 'backward';
export type IndicatorVolumeLevel = 'off' | 'low' | 'high';
export type IndicatorStatus =
| 'pause'
| 'play'
| 'volume-off'
| 'volume-low'
| 'volume-high'
| 'captions-on'
| 'captions-off'
| 'fullscreen'
| 'exit-fullscreen'
| 'pip'
| 'exit-pip';
export interface InputActionEvent {
action?: string | undefined;
value?: number | undefined;
source?: InputActionSource | undefined;
key?: string | undefined;
}
export interface MediaSnapshot {
paused?: boolean | undefined;
volume?: number | undefined;
muted?: boolean | undefined;
fullscreen?: boolean | undefined;
subtitlesShowing?: boolean | undefined;
pip?: boolean | undefined;
currentTime?: number | undefined;
duration?: number | undefined;
}
export interface InputIndicatorLabels {
muted: string;
volume: string;
captionsOn: string;
captionsOff: string;
paused: string;
playing: string;
fullscreen: string;
exitFullscreen: string;
pictureInPicture: string;
exitPictureInPicture: string;
}
export interface StatusDetails {
status: IndicatorStatus;
label: string;
value: string | null;
volumeLevel: IndicatorVolumeLevel | null;
}
export const DEFAULT_INPUT_INDICATOR_LABELS: InputIndicatorLabels = {
muted: 'Muted',
volume: 'Volume',
captionsOn: 'Captions on',
captionsOff: 'Captions off',
paused: 'Paused',
playing: 'Playing',
fullscreen: 'Fullscreen',
exitFullscreen: 'Exit fullscreen',
pictureInPicture: 'Picture in picture',
exitPictureInPicture: 'Exit picture in picture',
};
export function isVolumeIndicatorAction(action: string | null | undefined): action is 'toggleMuted' | 'volumeStep' {
return action === 'toggleMuted' || action === 'volumeStep';
}
export function isSeekIndicatorAction(action: string | null | undefined): action is 'seekStep' | 'seekToPercent' {
return action === 'seekStep' || action === 'seekToPercent';
}
export function deriveStatus(
event: InputActionEvent,
snapshot: MediaSnapshot,
labels: InputIndicatorLabels = DEFAULT_INPUT_INDICATOR_LABELS
): StatusDetails | null {
switch (event.action) {
case 'togglePaused': {
const paused = snapshot.paused !== undefined ? !snapshot.paused : true;
return {
status: paused ? 'pause' : 'play',
label: paused ? labels.paused : labels.playing,
value: null,
volumeLevel: null,
};
}
case 'toggleMuted':
case 'volumeStep':
return deriveVolumeStatus(event, snapshot, labels);
case 'toggleSubtitles': {
const showing = snapshot.subtitlesShowing !== undefined ? !snapshot.subtitlesShowing : true;
return {
status: showing ? 'captions-on' : 'captions-off',
label: showing ? labels.captionsOn : labels.captionsOff,
value: null,
volumeLevel: null,
};
}
case 'toggleFullscreen': {
const fullscreen = snapshot.fullscreen !== undefined ? !snapshot.fullscreen : true;
return {
status: fullscreen ? 'fullscreen' : 'exit-fullscreen',
label: fullscreen ? labels.fullscreen : labels.exitFullscreen,
value: null,
volumeLevel: null,
};
}
case 'togglePictureInPicture': {
const pip = snapshot.pip !== undefined ? !snapshot.pip : true;
return {
status: pip ? 'pip' : 'exit-pip',
label: pip ? labels.pictureInPicture : labels.exitPictureInPicture,
value: null,
volumeLevel: null,
};
}
default:
return null;
}
}
export function deriveAnnouncerLabel(
event: InputActionEvent,
snapshot: MediaSnapshot,
labels: InputIndicatorLabels = DEFAULT_INPUT_INDICATOR_LABELS
): string | null {
const details = deriveStatus(event, snapshot, labels);
if (!details) return null;
if (isVolumeIndicatorAction(event.action)) {
return details.status === 'volume-off' ? labels.muted : `${labels.volume} ${details.value}`;
}
return details.label;
}
export function getVolumeLevel(volume: number): IndicatorVolumeLevel {
if (volume <= 0) return 'off';
return volume <= 0.5 ? 'low' : 'high';
}
export function formatVolumeValue(volume: number): string {
return `${Math.round(clamp(volume, 0, 1) * 100)}%`;
}
export function formatCurrentTime(snapshot: MediaSnapshot): string {
return formatTime(snapshot.currentTime ?? 0, snapshot.duration);
}
export function getStatusIndicatorDisplayValue(state: { value: string | null; label: string | null }): string {
return state.value ?? state.label ?? '';
}
export function getVolumeIndicatorDisplayValue(state: { value: string | null }): string {
return state.value ?? '';
}
export function getSeekIndicatorDisplayValue(state: { value: string | null; currentTime: string }): string {
return state.value ?? state.currentTime;
}
export function getSeekToPercent(event: InputActionEvent): number | null {
if (event.value !== undefined) return clamp(event.value, 0, 100);
if (!event.key || event.key < '0' || event.key > '9') return null;
return Number(event.key) * 10;
}
export function getSeekDirection(event: InputActionEvent, snapshot: MediaSnapshot): IndicatorDirection | null {
if (event.action === 'seekStep' && event.value !== undefined) {
if (event.value > 0) return 'forward';
if (event.value < 0) return 'backward';
}
if (event.action === 'seekToPercent') {
const percent = getSeekToPercent(event);
if (percent === null || snapshot.duration === undefined || snapshot.duration <= 0) return null;
const targetTime = (percent / 100) * snapshot.duration;
const currentTime = snapshot.currentTime ?? 0;
if (targetTime > currentTime) return 'forward';
if (targetTime < currentTime) return 'backward';
}
return null;
}
export function isInputActionIncluded(
action: string | undefined,
actions: readonly InputAction[] | undefined
): boolean {
if (!action) return false;
return !actions || actions.includes(action);
}
/** Predicted mute/volume after a volume-indicator action — shared by status derivation and boundary detection. */
export interface VolumeActionPrediction {
snapshotVolume: number;
nextMuted: boolean;
nextVolume: number;
}
export function predictVolumeActionOutcome(event: InputActionEvent, snapshot: MediaSnapshot): VolumeActionPrediction {
const muted = snapshot.muted === true;
const snapshotVolume = snapshot.volume ?? 0;
if (event.action === 'toggleMuted') {
return { snapshotVolume, nextMuted: !muted, nextVolume: snapshotVolume };
}
if (event.action === 'volumeStep') {
const nextVolume = clamp(snapshotVolume + (event.value ?? 0), 0, 1);
/** Mirrors `volumeFeature.setVolume`: mute clears only when the clamped volume is greater than 0. */
const nextMuted = muted && nextVolume <= 0;
return { snapshotVolume, nextMuted, nextVolume };
}
return { snapshotVolume, nextMuted: muted, nextVolume: snapshotVolume };
}
function volumePredictionToStatusDetails(
prediction: VolumeActionPrediction,
labels: InputIndicatorLabels
): StatusDetails {
const level = prediction.nextMuted ? 'off' : getVolumeLevel(prediction.nextVolume);
const value = prediction.nextMuted ? '0%' : formatVolumeValue(prediction.nextVolume);
return {
status: level === 'off' ? 'volume-off' : level === 'low' ? 'volume-low' : 'volume-high',
label: level === 'off' ? labels.muted : labels.volume,
value,
volumeLevel: level,
};
}
/** Labels/value/level for volume actions — single source shared with `VolumeIndicatorCore`. */
export function deriveVolumeStatus(
event: InputActionEvent,
snapshot: MediaSnapshot,
labels: InputIndicatorLabels = DEFAULT_INPUT_INDICATOR_LABELS,
cachedPrediction?: VolumeActionPrediction
): StatusDetails {
const prediction = cachedPrediction ?? predictVolumeActionOutcome(event, snapshot);
return volumePredictionToStatusDetails(prediction, labels);
}
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import {
getRenderedIndicatorState,
type IndicatorLifecycleState,
IndicatorVisibilityCoordinator,
isIndicatorPresent,
} from '../indicator-lifecycle';
interface TestState extends IndicatorLifecycleState {
value: string | null;
}
const IDLE_STATE: TestState = {
open: false,
generation: 0,
value: null,
transitionStarting: false,
transitionEnding: false,
};
describe('indicator-lifecycle', () => {
it('keeps the snapshot payload while an indicator transitions out', () => {
const snapshot: TestState = {
...IDLE_STATE,
open: true,
generation: 1,
value: 'Paused',
};
const rendered = getRenderedIndicatorState(IDLE_STATE, snapshot, {
active: true,
status: 'ending',
});
expect(rendered.open).toBe(false);
expect(rendered.value).toBe('Paused');
expect(rendered.transitionEnding).toBe(true);
});
it('stays present until both logical state and transition are inactive', () => {
expect(isIndicatorPresent(IDLE_STATE, { active: true })).toBe(true);
expect(isIndicatorPresent(IDLE_STATE, { active: false })).toBe(false);
});
it('closes registered indicators when another indicator is shown', () => {
const coordinator = new IndicatorVisibilityCoordinator();
const first = { close: vi.fn() };
const second = { close: vi.fn() };
coordinator.register(first);
coordinator.register(second);
coordinator.show(second);
expect(first.close).toHaveBeenCalledOnce();
expect(second.close).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SeekIndicatorCore } from '../seek-indicator-core';
describe('SeekIndicatorCore', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('accumulates rapid seek steps in the same direction', () => {
const core = new SeekIndicatorCore();
core.processEvent({ action: 'seekStep', value: 10 }, { currentTime: 30, duration: 120 });
core.processEvent({ action: 'seekStep', value: 10 }, { currentTime: 30, duration: 120 });
expect(core.state.current.count).toBe(2);
expect(core.state.current.seekTotal).toBe(20);
expect(core.state.current.value).toBe('20s');
});
it('clamps accumulated seek steps to the available media range', () => {
const core = new SeekIndicatorCore();
const snapshot = { currentTime: 115, duration: 120 };
core.processEvent({ action: 'seekStep', value: 10 }, snapshot);
core.processEvent({ action: 'seekStep', value: 10 }, snapshot);
expect(core.state.current.seekTotal).toBe(10);
});
it('infers seek-to-percent direction and always keeps current-time text', () => {
const core = new SeekIndicatorCore();
core.processEvent({ action: 'seekToPercent', key: '8' }, { currentTime: 30, duration: 120 });
expect(core.state.current.direction).toBe('forward');
expect(core.state.current.value).toBeNull();
expect(core.state.current.currentTime).toBe('0:30');
});
it('closes and resets accumulation after the configured delay', () => {
const core = new SeekIndicatorCore();
core.setProps({ closeDelay: 100 });
core.processEvent({ action: 'seekStep', value: -10 }, { currentTime: 30, duration: 120 });
vi.advanceTimersByTime(100);
expect(core.state.current.open).toBe(false);
expect(core.state.current.count).toBe(0);
});
});
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StatusAnnouncerCore } from '../status-announcer-core';
import { StatusIndicatorCore } from '../status-indicator-core';
describe('StatusIndicatorCore', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('honors the optional action filter', () => {
const core = new StatusIndicatorCore();
core.setProps({ actions: ['toggleSubtitles'] });
expect(core.processEvent({ action: 'togglePaused' }, { paused: false })).toBe(false);
expect(core.processEvent({ action: 'toggleSubtitles' }, { subtitlesShowing: false })).toBe(true);
expect(core.state.current.status).toBe('captions-on');
});
it('increments generation on each accepted trigger', () => {
const core = new StatusIndicatorCore();
core.processEvent({ action: 'togglePaused' }, { paused: false });
core.processEvent({ action: 'togglePaused' }, { paused: false });
expect(core.state.current.generation).toBe(2);
});
it('clears after the configured delay', () => {
const core = new StatusIndicatorCore();
core.setProps({ closeDelay: 100 });
core.processEvent({ action: 'toggleFullscreen' }, { fullscreen: false });
vi.advanceTimersByTime(100);
expect(core.state.current.open).toBe(false);
expect(core.state.current.status).toBeNull();
});
});
describe('StatusAnnouncerCore', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('announces status labels and clears them after the delay', () => {
const core = new StatusAnnouncerCore();
core.setProps({ closeDelay: 100 });
expect(core.processEvent({ action: 'volumeStep', value: 0.1 }, { volume: 0.5, muted: false })).toBe(true);
expect(core.state.current.label).toBe('Volume 60%');
vi.advanceTimersByTime(100);
expect(core.state.current.label).toBeNull();
});
});
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
deriveAnnouncerLabel,
deriveStatus,
getSeekDirection,
getSeekIndicatorDisplayValue,
getStatusIndicatorDisplayValue,
getVolumeIndicatorDisplayValue,
type MediaSnapshot,
predictVolumeActionOutcome,
} from '../status';
const SNAPSHOT: MediaSnapshot = {
paused: false,
volume: 0.5,
muted: false,
fullscreen: false,
subtitlesShowing: false,
pip: false,
currentTime: 30,
duration: 120,
};
describe('status', () => {
it('derives playback status from the expected next state', () => {
expect(deriveStatus({ action: 'togglePaused' }, SNAPSHOT)).toMatchObject({
status: 'pause',
label: 'Paused',
});
expect(deriveStatus({ action: 'togglePaused' }, { ...SNAPSHOT, paused: true })).toMatchObject({
status: 'play',
label: 'Playing',
});
});
it('derives volume status, value, and announcer labels', () => {
expect(deriveStatus({ action: 'volumeStep', value: 0.3 }, SNAPSHOT)).toMatchObject({
status: 'volume-high',
label: 'Volume',
value: '80%',
volumeLevel: 'high',
});
expect(deriveAnnouncerLabel({ action: 'volumeStep', value: 0.3 }, SNAPSHOT)).toBe('Volume 80%');
expect(deriveAnnouncerLabel({ action: 'toggleMuted' }, SNAPSHOT)).toBe('Muted');
});
it('derives captions, fullscreen, and picture-in-picture statuses', () => {
expect(deriveStatus({ action: 'toggleSubtitles' }, SNAPSHOT)?.status).toBe('captions-on');
expect(deriveStatus({ action: 'toggleFullscreen' }, SNAPSHOT)?.status).toBe('fullscreen');
expect(deriveStatus({ action: 'toggleFullscreen' }, { ...SNAPSHOT, fullscreen: true })?.status).toBe(
'exit-fullscreen'
);
expect(deriveStatus({ action: 'togglePictureInPicture' }, SNAPSHOT)?.status).toBe('pip');
expect(deriveStatus({ action: 'togglePictureInPicture' }, { ...SNAPSHOT, pip: true })?.status).toBe('exit-pip');
});
it('does not derive status or values for seek and unsupported actions', () => {
expect(deriveStatus({ action: 'seekStep', value: 10 }, SNAPSHOT)).toBeNull();
expect(deriveStatus({ action: 'seekToPercent', value: 50 }, SNAPSHOT)?.value ?? null).toBeNull();
expect(deriveStatus({ action: 'speedUp' }, SNAPSHOT)).toBeNull();
});
it('predicts volume outcome like volumeFeature.setVolume when muted', () => {
expect(predictVolumeActionOutcome({ action: 'volumeStep', value: 0.05 }, { muted: true, volume: 0.5 })).toEqual({
snapshotVolume: 0.5,
nextMuted: false,
nextVolume: 0.55,
});
expect(predictVolumeActionOutcome({ action: 'volumeStep', value: -0.05 }, { muted: true, volume: 0.05 })).toEqual({
snapshotVolume: 0.05,
nextMuted: true,
nextVolume: 0,
});
});
it('infers seek direction from action details', () => {
expect(getSeekDirection({ action: 'seekStep', value: -10 }, SNAPSHOT)).toBe('backward');
expect(getSeekDirection({ action: 'seekToPercent', key: '8' }, SNAPSHOT)).toBe('forward');
});
it('derives display values for mounted indicators', () => {
expect(getStatusIndicatorDisplayValue({ label: 'Paused', value: null })).toBe('Paused');
expect(getVolumeIndicatorDisplayValue({ value: null })).toBe('');
expect(getSeekIndicatorDisplayValue({ value: null, currentTime: '0:30' })).toBe('0:30');
});
});
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { VolumeIndicatorCore } from '../volume-indicator-core';
describe('VolumeIndicatorCore', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('opens with scoped volume state for volume actions only', () => {
const core = new VolumeIndicatorCore();
expect(core.processEvent({ action: 'togglePaused' }, { volume: 0.5, muted: false })).toBe(false);
expect(core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.5, muted: false })).toBe(true);
expect(core.state.current.open).toBe(true);
expect(core.state.current.level).toBe('high');
expect(core.state.current.value).toBe('55%');
expect(core.state.current.fill).toBe('55%');
});
it('accumulates volume steps from sequential media snapshots', () => {
const core = new VolumeIndicatorCore();
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.5, muted: false });
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.55, muted: false });
expect(core.state.current.value).toBe('60%');
});
it('uses snapshot volume after mute feedback showed 0%', () => {
const core = new VolumeIndicatorCore();
core.processEvent({ action: 'toggleMuted' }, { volume: 0.5, muted: false });
expect(core.state.current.value).toBe('0%');
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.5, muted: true });
expect(core.state.current.value).toBe('55%');
});
it('does not treat a zero volume step as a min/max boundary hit', () => {
const core = new VolumeIndicatorCore();
core.processEvent({ action: 'volumeStep', value: 0 }, { volume: 1, muted: false });
expect(core.state.current.max).toBe(false);
expect(core.state.current.min).toBe(false);
core.processEvent({ action: 'volumeStep', value: 0 }, { volume: 0, muted: false });
expect(core.state.current.max).toBe(false);
expect(core.state.current.min).toBe(false);
});
it('restarts the boundary flag when the same edge is hit repeatedly', () => {
const core = new VolumeIndicatorCore();
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 1, muted: false });
expect(core.state.current.max).toBe(true);
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 1, muted: false });
expect(core.state.current.max).toBe(false);
vi.advanceTimersByTime(0);
expect(core.state.current.max).toBe(true);
vi.advanceTimersByTime(300);
expect(core.state.current.max).toBe(false);
});
it('closes after the configured delay', () => {
const core = new VolumeIndicatorCore();
core.setProps({ closeDelay: 100 });
core.processEvent({ action: 'toggleMuted' }, { volume: 0.5, muted: false });
vi.advanceTimersByTime(100);
expect(core.state.current.open).toBe(false);
expect(core.state.current.value).toBeNull();
});
});
@@ -0,0 +1,142 @@
import { createState } from '@videojs/store';
import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle';
import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle';
import {
DEFAULT_INPUT_INDICATOR_LABELS,
deriveVolumeStatus,
type IndicatorVolumeLevel,
type InputActionEvent,
isVolumeIndicatorAction,
type MediaSnapshot,
predictVolumeActionOutcome,
} from './status';
export interface VolumeIndicatorProps extends IndicatorCoreProps {}
export interface VolumeIndicatorState extends IndicatorLifecycleState {
level: IndicatorVolumeLevel | null;
value: string | null;
fill: string | null;
min: boolean;
max: boolean;
}
const BOUNDARY_CLEAR_DELAY = 300;
const INITIAL_STATE: VolumeIndicatorState = {
open: false,
generation: 0,
level: null,
value: null,
fill: null,
min: false,
max: false,
transitionStarting: false,
transitionEnding: false,
};
export class VolumeIndicatorCore {
readonly state = createState<VolumeIndicatorState>({ ...INITIAL_STATE });
#props: VolumeIndicatorProps = {};
#boundaryTimer: ReturnType<typeof setTimeout> | null = null;
#boundaryRestartTimer: ReturnType<typeof setTimeout> | null = null;
#close = new IndicatorCloseController(
() => this.state.patch({ open: false, level: null, value: null, fill: null, min: false, max: false }),
() => getIndicatorCloseDelay(this.#props)
);
setProps(props: VolumeIndicatorProps): void {
this.#props = props;
}
destroy(): void {
this.#close.destroy();
this.#clearBoundaryTimers();
}
close(): void {
this.#clearBoundaryTimers();
this.#close.close();
}
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean {
if (!isVolumeIndicatorAction(event.action)) return false;
const current = this.state.current;
const prediction = predictVolumeActionOutcome(event, snapshot);
const details = deriveVolumeStatus(event, snapshot, DEFAULT_INPUT_INDICATOR_LABELS, prediction);
const boundary = getVolumeBoundary(event, prediction.snapshotVolume, prediction.nextVolume);
const repeatedBoundary = boundary !== null && current[boundary] === true;
if (!boundary) this.#clearBoundaryTimers();
this.state.patch({
open: true,
generation: current.generation + 1,
level: details.volumeLevel,
value: details.value,
fill: details.value,
min: boundary === 'min' && !repeatedBoundary,
max: boundary === 'max' && !repeatedBoundary,
});
if (boundary) {
if (repeatedBoundary) {
this.#restartBoundary(boundary);
} else {
this.#scheduleBoundaryClear();
}
}
this.#close.arm();
return true;
}
#scheduleBoundaryClear(): void {
this.#clearBoundaryTimer();
this.#boundaryTimer = setTimeout(() => {
this.#boundaryTimer = null;
this.state.patch({ min: false, max: false });
}, BOUNDARY_CLEAR_DELAY);
}
#restartBoundary(boundary: 'min' | 'max'): void {
this.#clearBoundaryTimers();
this.state.patch({ min: false, max: false });
this.#boundaryRestartTimer = setTimeout(() => {
this.#boundaryRestartTimer = null;
this.state.patch({ [boundary]: true });
this.#scheduleBoundaryClear();
}, 0);
}
#clearBoundaryTimer(): void {
if (this.#boundaryTimer === null) return;
clearTimeout(this.#boundaryTimer);
this.#boundaryTimer = null;
}
#clearBoundaryRestartTimer(): void {
if (this.#boundaryRestartTimer === null) return;
clearTimeout(this.#boundaryRestartTimer);
this.#boundaryRestartTimer = null;
}
#clearBoundaryTimers(): void {
this.#clearBoundaryTimer();
this.#clearBoundaryRestartTimer();
}
}
export namespace VolumeIndicatorCore {
export type Props = VolumeIndicatorProps;
export type State = VolumeIndicatorState;
}
function getVolumeBoundary(event: InputActionEvent, currentVolume: number, nextVolume: number): 'min' | 'max' | null {
if (event.action !== 'volumeStep' || event.value === undefined || event.value === 0) return null;
if (nextVolume !== currentVolume) return null;
return event.value < 0 ? 'min' : 'max';
}
@@ -0,0 +1,3 @@
export const VolumeIndicatorCSSVars = {
fill: '--media-volume-fill',
} as const;
@@ -0,0 +1,11 @@
import type { StateAttrMap } from '../types';
import type { VolumeIndicatorState } from './volume-indicator-core';
export const VolumeIndicatorDataAttrs = {
open: 'data-open',
level: 'data-level',
min: 'data-min',
max: 'data-max',
transitionStarting: 'data-starting-style',
transitionEnding: 'data-ending-style',
} as const satisfies StateAttrMap<VolumeIndicatorState>;
+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();
});
});