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
+6
View File
@@ -33,6 +33,8 @@ export interface MediaPlaybackState {
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause
*/
pause(): void;
/** Toggle play/pause. Returns `true` if playback started. */
togglePaused(): boolean;
}
/** Indicates whether a feature can be programmatically controlled on this platform. */
@@ -161,6 +163,8 @@ export interface MediaFullscreenState {
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitFullscreen
*/
exitFullscreen(): Promise<void>;
/** Toggle fullscreen mode. */
toggleFullscreen(): Promise<void>;
}
export interface MediaControlsState {
@@ -296,4 +300,6 @@ export interface MediaPictureInPictureState {
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitPictureInPicture
*/
exitPictureInPicture(): Promise<void>;
/** Toggle picture-in-picture mode. */
togglePictureInPicture(): Promise<void>;
}
@@ -12,6 +12,7 @@ function createMediaState(overrides: Partial<MediaPlaybackState> = {}): MediaPla
waiting: false,
play: vi.fn(async () => {}),
pause: vi.fn(),
togglePaused: vi.fn(() => true),
...overrides,
};
}
@@ -10,6 +10,7 @@ function createMediaState(overrides: Partial<MediaFullscreenState> = {}): MediaF
fullscreenAvailability: 'available',
requestFullscreen: vi.fn(async () => {}),
exitFullscreen: vi.fn(async () => {}),
toggleFullscreen: vi.fn(async () => {}),
...overrides,
};
}
@@ -10,6 +10,7 @@ function createMediaState(overrides: Partial<MediaPictureInPictureState> = {}):
pipAvailability: 'available',
requestPictureInPicture: vi.fn(async () => {}),
exitPictureInPicture: vi.fn(async () => {}),
togglePictureInPicture: vi.fn(async () => {}),
...overrides,
};
}
@@ -12,6 +12,7 @@ function createMediaState(overrides: Partial<MediaPlaybackState> = {}): MediaPla
waiting: false,
play: vi.fn(async () => {}),
pause: vi.fn(),
togglePaused: vi.fn(() => true),
...overrides,
};
}
@@ -11,6 +11,7 @@ function createMediaState(overrides: Partial<MediaPlaybackState> = {}): MediaPla
waiting: false,
play: vi.fn(async () => {}),
pause: vi.fn(),
togglePaused: vi.fn(() => true),
...overrides,
};
}
+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,
};
}
+2 -2
View File
@@ -16,7 +16,7 @@ export type HotkeyActionName =
| 'toggleMuted'
| 'toggleFullscreen'
| 'toggleSubtitles'
| 'togglePiP'
| 'togglePictureInPicture'
| 'seekStep'
| 'volumeStep'
| 'speedUp'
@@ -57,7 +57,7 @@ const HOTKEY_ACTIONS: Record<HotkeyActionName, HotkeyActionResolver> = {
selectTextTrack(store.state)?.toggleSubtitles();
},
togglePiP({ store }) {
togglePictureInPicture({ store }) {
const pip = selectPiP(store.state);
if (!pip) return;
pip.pip ? pip.exitPictureInPicture() : pip.requestPictureInPicture();
@@ -36,7 +36,7 @@ describe('isHotkeyToggleAction', () => {
expect(isHotkeyToggleAction('toggleMuted')).toBe(true);
expect(isHotkeyToggleAction('toggleFullscreen')).toBe(true);
expect(isHotkeyToggleAction('toggleSubtitles')).toBe(true);
expect(isHotkeyToggleAction('togglePiP')).toBe(true);
expect(isHotkeyToggleAction('togglePictureInPicture')).toBe(true);
});
it('returns false for non-toggle actions', () => {
+1
View File
@@ -1,4 +1,5 @@
export * from './feature';
export * from './gesture/actions';
export * from './gesture/coordinator';
export * from './gesture/create-tap-gesture';
export * from './gesture/gesture';
@@ -32,6 +32,20 @@ export const fullscreenFeature = definePlayerFeature({
const { media } = target();
return exitFullscreen(media);
},
async toggleFullscreen() {
const { media, container } = target();
if (isFullscreenElement(container, media)) {
return exitFullscreen(media);
}
if (isPictureInPictureElement(media)) {
await exitPictureInPicture(media);
}
return requestFullscreen(container, media);
},
}),
attach({ target, signal, set }) {
@@ -32,6 +32,20 @@ export const pipFeature = definePlayerFeature({
const { media } = target();
return exitPictureInPicture(media);
},
async togglePictureInPicture() {
const { media, container } = target();
if (isPictureInPictureElement(media)) {
return exitPictureInPicture(media);
}
if (isFullscreenElement(container, media)) {
await exitFullscreen();
}
return requestPictureInPicture(media);
},
}),
attach({ target, signal, set }) {
@@ -16,6 +16,15 @@ export const playbackFeature = definePlayerFeature({
pause() {
target().media.pause();
},
togglePaused() {
const media = target().media;
if (media.paused) {
media.play();
return true;
}
media.pause();
return false;
},
}),
attach({ target, signal, set }) {
+1 -1
View File
@@ -137,7 +137,7 @@ function getTemplateHTML() {
<media-hotkey keys="m" action="toggleMuted"></media-hotkey>
<media-hotkey keys="f" action="toggleFullscreen"></media-hotkey>
<media-hotkey keys="c" action="toggleSubtitles"></media-hotkey>
<media-hotkey keys="i" action="togglePiP"></media-hotkey>
<media-hotkey keys="i" action="togglePictureInPicture"></media-hotkey>
<media-hotkey keys="ArrowRight" action="seekStep" value="5"></media-hotkey>
<media-hotkey keys="ArrowLeft" action="seekStep" value="-5"></media-hotkey>
<media-hotkey keys="l" action="seekStep" value="10"></media-hotkey>
+1
View File
@@ -28,6 +28,7 @@ export { ControlsElement } from './ui/controls/controls-element';
export { ControlsGroupElement } from './ui/controls/controls-group-element';
export { ErrorDialogElement } from './ui/error-dialog/error-dialog-element';
export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element';
export { GestureElement } from './ui/gesture/gesture-element';
export { AriaKeyShortcutsController } from './ui/hotkey/aria-key-shortcuts-controller';
export { HotkeyElement } from './ui/hotkey/hotkey-element';
export { MediaButtonElement } from './ui/media-button-element';
@@ -0,0 +1,95 @@
import {
createDoubleTapGesture,
createTapGesture,
type GestureActionName,
type GesturePointerType,
type GestureRegion,
resolveGestureAction,
} from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { containerContext, playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
export class GestureElement extends MediaElement {
static readonly tagName = 'media-gesture';
static override properties: PropertyDeclarationMap = {
type: { type: String },
action: { type: String },
value: { type: Number },
pointer: { type: String },
region: { type: String },
disabled: { type: Boolean },
};
type: 'tap' | 'doubletap' | (string & {}) = '';
action: GestureActionName | (string & {}) = '';
value: number | undefined = undefined;
pointer: GesturePointerType | undefined = undefined;
region: GestureRegion | undefined = undefined;
disabled = false;
readonly #player = new PlayerController(this, playerContext);
readonly #container = new ContextConsumer(this, {
context: containerContext,
callback: () => this.requestUpdate(),
subscribe: true,
});
#cleanup: (() => void) | null = null;
override connectedCallback(): void {
super.connectedCallback();
this.style.display = 'none';
this.#register();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#unregister();
}
protected override update(changed: PropertyValues): void {
super.update(changed);
// Re-register when attributes change.
if (this.isConnected) {
this.#unregister();
this.#register();
}
}
#register(): void {
const store = this.#player.value;
const container = this.#container.value?.container;
if (!this.type || !this.action || !store || !container) return;
const resolver = resolveGestureAction(this.action);
if (!resolver) return;
const { value } = this;
const onActivate = (event: PointerEvent) => {
resolver({ store, value, event });
};
const options = {
pointer: this.pointer,
region: this.region,
disabled: this.disabled,
};
if (this.type === 'doubletap') {
this.#cleanup = createDoubleTapGesture(container, onActivate, options);
} else {
this.#cleanup = createTapGesture(container, onActivate, options);
}
}
#unregister(): void {
this.#cleanup?.();
this.#cleanup = null;
}
}
@@ -0,0 +1,43 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { GestureElement } from '../gesture-element';
beforeAll(() => {
customElements.define('media-gesture', GestureElement);
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('GestureElement', () => {
it('has the correct tag name', () => {
expect(GestureElement.tagName).toBe('media-gesture');
});
it('declares expected properties', () => {
const props = GestureElement.properties;
expect(props).toHaveProperty('type');
expect(props).toHaveProperty('action');
expect(props).toHaveProperty('value');
expect(props).toHaveProperty('pointer');
expect(props).toHaveProperty('region');
expect(props).toHaveProperty('disabled');
});
it('initializes with default property values', () => {
const el = document.createElement('media-gesture') as GestureElement;
expect(el.type).toBe('');
expect(el.action).toBe('');
expect(el.value).toBeUndefined();
expect(el.pointer).toBeUndefined();
expect(el.region).toBeUndefined();
expect(el.disabled).toBe(false);
});
it('is hidden when connected', () => {
const el = document.createElement('media-gesture') as GestureElement;
document.body.appendChild(el);
expect(el.style.display).toBe('none');
});
});
@@ -11,7 +11,7 @@ export class PiPButtonElement extends MediaButtonElement<PiPButtonCore> {
protected readonly core = new PiPButtonCore();
protected readonly stateAttrMap = PiPButtonDataAttrs;
protected readonly mediaState = new PlayerController(this, playerContext, selectPiP);
protected override readonly hotkeyAction = 'togglePiP';
protected override readonly hotkeyAction = 'togglePictureInPicture';
protected activate(state: MediaPictureInPictureState): void {
this.core.toggle(state);
+1 -1
View File
@@ -237,7 +237,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<MediaHotkey keys="m" action="toggleMuted" />
<MediaHotkey keys="f" action="toggleFullscreen" />
<MediaHotkey keys="c" action="toggleSubtitles" />
<MediaHotkey keys="i" action="togglePiP" />
<MediaHotkey keys="i" action="togglePictureInPicture" />
<MediaHotkey keys="ArrowRight" action="seekStep" value={5} />
<MediaHotkey keys="ArrowLeft" action="seekStep" value={-5} />
<MediaHotkey keys="l" action="seekStep" value={10} />
@@ -15,7 +15,7 @@ export const PiPButton = createMediaButton<PiPButtonCore, PiPButtonProps>({
stateAttrMap: PiPButtonDataAttrs,
selector: selectPiP,
action: (core, state) => core.toggle(state),
hotkeyAction: 'togglePiP',
hotkeyAction: 'togglePictureInPicture',
});
export namespace PiPButton {