feat(html): add <media-gesture> element (#1305)

This commit is contained in:
rahim
2026-04-10 18:14:17 -07:00
committed by GitHub
parent 12877c34fc
commit d2782f0c8d
21 changed files with 400 additions and 7 deletions
+71
View File
@@ -0,0 +1,71 @@
import { isFunction, isUndefined } from '@videojs/utils/predicate';
import type { AnyPlayerStore } from '../media/types';
import { selectPlaybackRate, selectTime, selectVolume } from '../store/selectors';
export type GestureActionName =
| 'togglePaused'
| 'toggleMuted'
| 'toggleFullscreen'
| 'toggleSubtitles'
| 'togglePictureInPicture'
| 'toggleControls'
| 'seekStep'
| 'volumeStep'
| 'speedUp'
| 'speedDown';
export interface GestureActionContext {
store: AnyPlayerStore;
value?: number | undefined;
event: PointerEvent;
}
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);
},
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]!);
},
};
export function resolveGestureAction(name: GestureActionName | (string & {})): GestureActionResolver | undefined {
const override = GESTURE_ACTION_OVERRIDES[name as GestureActionName];
if (override) return override;
// Direct store method call — togglePaused, toggleMuted, toggleFullscreen, etc.
return ({ store }) => {
const method = (store.state as Record<string, unknown>)[name];
if (isFunction(method)) method();
else if (__DEV__) console.warn(`[vjs-gesture] Unknown action: "${name}"`);
};
}
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest';
import type { GestureActionContext } from '../actions';
import { resolveGestureAction } from '../actions';
describe('resolveGestureAction', () => {
it('returns a resolver for override actions', () => {
expect(resolveGestureAction('seekStep')).toBeTypeOf('function');
expect(resolveGestureAction('volumeStep')).toBeTypeOf('function');
expect(resolveGestureAction('speedUp')).toBeTypeOf('function');
expect(resolveGestureAction('speedDown')).toBeTypeOf('function');
});
it('returns a resolver for direct store actions', () => {
expect(resolveGestureAction('togglePaused')).toBeTypeOf('function');
expect(resolveGestureAction('toggleMuted')).toBeTypeOf('function');
expect(resolveGestureAction('toggleFullscreen')).toBeTypeOf('function');
expect(resolveGestureAction('toggleSubtitles')).toBeTypeOf('function');
expect(resolveGestureAction('togglePictureInPicture')).toBeTypeOf('function');
expect(resolveGestureAction('toggleControls')).toBeTypeOf('function');
});
it('always returns a resolver (warns for unknown in __DEV__)', () => {
const resolver = resolveGestureAction('nonexistent');
expect(resolver).toBeTypeOf('function');
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
resolver!(ctx({}));
expect(spy).toHaveBeenCalledWith('[vjs-gesture] Unknown action: "nonexistent"');
spy.mockRestore();
});
});
describe('direct store actions', () => {
it('calls togglePaused on store state', () => {
const togglePaused = vi.fn();
resolveGestureAction('togglePaused')!(ctx({ togglePaused }));
expect(togglePaused).toHaveBeenCalledOnce();
});
it('calls toggleMuted on store state', () => {
const toggleMuted = vi.fn();
resolveGestureAction('toggleMuted')!(ctx({ toggleMuted }));
expect(toggleMuted).toHaveBeenCalledOnce();
});
it('calls toggleFullscreen on store state', () => {
const toggleFullscreen = vi.fn();
resolveGestureAction('toggleFullscreen')!(ctx({ toggleFullscreen }));
expect(toggleFullscreen).toHaveBeenCalledOnce();
});
it('calls toggleControls on store state', () => {
const toggleControls = vi.fn();
resolveGestureAction('toggleControls')!(ctx({ toggleControls }));
expect(toggleControls).toHaveBeenCalledOnce();
});
it('calls toggleSubtitles on store state', () => {
const toggleSubtitles = vi.fn();
resolveGestureAction('toggleSubtitles')!(ctx({ toggleSubtitles }));
expect(toggleSubtitles).toHaveBeenCalledOnce();
});
it('calls togglePictureInPicture on store state', () => {
const togglePictureInPicture = vi.fn();
resolveGestureAction('togglePictureInPicture')!(ctx({ togglePictureInPicture }));
expect(togglePictureInPicture).toHaveBeenCalledOnce();
});
});
describe('seekStep', () => {
it('seeks by value offset', () => {
const seek = vi.fn();
resolveGestureAction('seekStep')!(ctx({ currentTime: 10, duration: 60, seeking: false, seek }, 5));
expect(seek).toHaveBeenCalledWith(15);
});
it('does nothing without value', () => {
const seek = vi.fn();
resolveGestureAction('seekStep')!(ctx({ currentTime: 10, duration: 60, seeking: false, seek }));
expect(seek).not.toHaveBeenCalled();
});
});
describe('volumeStep', () => {
it('adjusts volume by value offset', () => {
const setVolume = vi.fn();
resolveGestureAction('volumeStep')!(
ctx({ volume: 0.5, muted: false, volumeAvailability: 'available', setVolume, toggleMuted: vi.fn() }, 0.1)
);
expect(setVolume).toHaveBeenCalledWith(0.6);
});
});
describe('speedUp', () => {
it('cycles to next playback rate', () => {
const setPlaybackRate = vi.fn();
resolveGestureAction('speedUp')!(ctx({ playbackRates: [0.5, 1, 1.5, 2], playbackRate: 1, setPlaybackRate }));
expect(setPlaybackRate).toHaveBeenCalledWith(1.5);
});
it('wraps to first rate at end', () => {
const setPlaybackRate = vi.fn();
resolveGestureAction('speedUp')!(ctx({ playbackRates: [0.5, 1, 2], playbackRate: 2, setPlaybackRate }));
expect(setPlaybackRate).toHaveBeenCalledWith(0.5);
});
});
describe('speedDown', () => {
it('cycles to previous playback rate', () => {
const setPlaybackRate = vi.fn();
resolveGestureAction('speedDown')!(ctx({ playbackRates: [0.5, 1, 1.5, 2], playbackRate: 1.5, setPlaybackRate }));
expect(setPlaybackRate).toHaveBeenCalledWith(1);
});
it('wraps to last rate at beginning', () => {
const setPlaybackRate = vi.fn();
resolveGestureAction('speedDown')!(ctx({ playbackRates: [0.5, 1, 2], playbackRate: 0.5, setPlaybackRate }));
expect(setPlaybackRate).toHaveBeenCalledWith(2);
});
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function ctx(stateProps: Record<string, unknown>, value?: number): GestureActionContext {
return {
store: { state: stateProps } as unknown as GestureActionContext['store'],
value,
event: new Event('pointerup') as PointerEvent,
};
}