feat(packages): add gesture bindings to default skins and presets (#1310)

This commit is contained in:
rahim
2026-04-10 22:08:10 -07:00
committed by GitHub
parent 8566260872
commit 1509a66f60
12 changed files with 53 additions and 3 deletions
+6 -1
View File
@@ -1,4 +1,4 @@
import { listen } from '@videojs/utils/dom';
import { isInteractiveTarget, listen } from '@videojs/utils/dom';
import type { GestureBinding, GestureMatchResult, GestureRecognizer, GestureRegion, GestureType } from './gesture';
import { resolveRegion } from './region';
@@ -15,6 +15,10 @@ export class GestureCoordinator {
this.#target = target;
}
get bindings(): readonly GestureBinding[] {
return this.#bindings;
}
add(binding: GestureBinding): () => void {
this.#bindings.push(binding);
this.#recognizers.add(binding.recognizer);
@@ -55,6 +59,7 @@ export class GestureCoordinator {
'pointerup',
(event) => {
if (Date.now() - pointerDownTime > TAP_THRESHOLD) return;
if (isInteractiveTarget(event)) return;
const pointerType = event.pointerType;
const clientX = event.clientX;
@@ -35,6 +35,7 @@ export function createTapGesture(
pointer: options?.pointer,
region: options?.region,
disabled: options?.disabled,
action: options?.action,
});
}
@@ -60,5 +61,6 @@ export function createDoubleTapGesture(
pointer: options?.pointer,
region: options?.region,
disabled: options?.disabled,
action: options?.action,
});
}
+2
View File
@@ -8,6 +8,7 @@ export interface GestureOptions {
pointer?: GesturePointerType | undefined;
region?: GestureRegion | undefined;
disabled?: boolean | undefined;
action?: string | undefined;
}
export interface GestureBinding {
@@ -17,6 +18,7 @@ export interface GestureBinding {
pointer?: GesturePointerType | undefined;
region?: GestureRegion | undefined;
disabled?: boolean | undefined;
action?: string | undefined;
}
export interface GestureRecognizer {
@@ -3,6 +3,7 @@ import { isNull } from '@videojs/utils/predicate';
import type { MediaControlsState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
import { findGestureCoordinator } from '../../gesture/coordinator';
const IDLE_DELAY = 2000;
const TAP_THRESHOLD = 250;
@@ -80,7 +81,17 @@ export const controlsFeature = definePlayerFeature({
function onPointerUp(event: PointerEvent) {
if (event.pointerType === 'touch' && Date.now() - pointerDownTime < TAP_THRESHOLD) {
// If the event target is in the controls don't set inactive because that sets pointer-events: none in CSS.
// When a toggleControls touch tap gesture is registered, it handles toggle — skip inline handler.
const coordinator = findGestureCoordinator(container as HTMLElement);
if (
coordinator?.bindings.some(
(b) => b.type === 'tap' && b.action === 'toggleControls' && (!b.pointer || b.pointer === 'touch')
)
) {
return;
}
// Inline touch tap-to-toggle for standalone use (no gestures).
const isMediaOrContainer = [media, container].includes(event.target as HTMLElement);
if (get().controlsVisible && isMediaOrContainer) {
setInactive();
+2
View File
@@ -2,6 +2,7 @@
// without creating a skin element. Use this entry when building an ejected
// (light DOM) player layout.
import { MediaContainerElement } from '../../media/container-element';
import { GestureElement } from '../../ui/gesture/gesture-element';
import { HotkeyElement } from '../../ui/hotkey/hotkey-element';
import { MuteButtonElement } from '../../ui/mute-button/mute-button-element';
import { PlayButtonElement } from '../../ui/play-button/play-button-element';
@@ -28,6 +29,7 @@ defineVolumeSlider();
defineTime();
// Standalone elements.
safeDefine(GestureElement);
safeDefine(HotkeyElement);
safeDefine(MuteButtonElement);
safeDefine(PlayButtonElement);
+7
View File
@@ -149,6 +149,13 @@ function getTemplateHTML() {
<media-hotkey keys="End" action="seekToPercent" value="100"></media-hotkey>
<media-hotkey keys=">" action="speedUp"></media-hotkey>
<media-hotkey keys="<" action="speedDown"></media-hotkey>
<!-- Gestures -->
<media-gesture type="tap" action="togglePaused" pointer="mouse" region="center"></media-gesture>
<media-gesture type="tap" action="toggleControls" pointer="touch"></media-gesture>
<media-gesture type="doubletap" action="seekStep" value="-10" region="left"></media-gesture>
<media-gesture type="doubletap" action="toggleFullscreen" region="center"></media-gesture>
<media-gesture type="doubletap" action="seekStep" value="10" region="right"></media-gesture>
</media-container>
`;
}
+2
View File
@@ -5,6 +5,7 @@ import { MediaContainerElement } from '../../media/container-element';
import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element';
import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element';
import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element';
import { GestureElement } from '../../ui/gesture/gesture-element';
import { HotkeyElement } from '../../ui/hotkey/hotkey-element';
import { MuteButtonElement } from '../../ui/mute-button/mute-button-element';
import { PiPButtonElement } from '../../ui/pip-button/pip-button-element';
@@ -37,6 +38,7 @@ defineTime();
safeDefine(BufferingIndicatorElement);
safeDefine(CaptionsButtonElement);
safeDefine(FullscreenButtonElement);
safeDefine(GestureElement);
safeDefine(HotkeyElement);
safeDefine(MuteButtonElement);
safeDefine(PiPButtonElement);
@@ -79,6 +79,7 @@ export class GestureElement extends MediaElement {
pointer: this.pointer,
region: this.region,
disabled: this.disabled,
action: this.action,
};
if (this.type === 'doubletap') {
@@ -23,6 +23,7 @@ import { CaptionsButton } from '@/ui/captions-button';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
import { MediaGesture } from '@/ui/gesture/media-gesture';
import { MediaHotkey } from '@/ui/hotkey/media-hotkey';
import { MuteButton } from '@/ui/mute-button';
import { PiPButton } from '@/ui/pip-button';
@@ -249,6 +250,13 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<MediaHotkey keys="End" action="seekToPercent" value={100} />
<MediaHotkey keys=">" action="speedUp" />
<MediaHotkey keys="<" action="speedDown" />
{/* Gestures */}
<MediaGesture type="tap" action="togglePaused" pointer="mouse" region="center" />
<MediaGesture type="tap" action="toggleControls" pointer="touch" />
<MediaGesture type="doubletap" action="seekStep" value={-10} region="left" />
<MediaGesture type="doubletap" action="toggleFullscreen" region="center" />
<MediaGesture type="doubletap" action="seekStep" value={10} region="right" />
</Container>
);
}
@@ -37,7 +37,7 @@ export function MediaGesture({ type, action, value, pointer, region, disabled }:
resolver({ store, value, event });
};
const options = { pointer, region };
const options = { pointer, region, action };
if (type === 'doubletap') {
return createDoubleTapGesture(container, onActivate, options);
+1
View File
@@ -12,6 +12,7 @@ export {
isHTMLAudioElement,
isHTMLMediaElement,
isHTMLVideoElement,
isInteractiveTarget,
} from './predicates';
export { type RafThrottled, rafThrottle } from './raf-throttle';
export {
+9
View File
@@ -33,3 +33,12 @@ export function isEditableTarget(event: KeyboardEvent): boolean {
const target = resolveEventTarget(event);
return target instanceof Element && isEditableElement(target);
}
const INTERACTIVE_SELECTOR = 'button, input, select, textarea, [role="button"], [role="slider"]';
/** Whether the event originated from an interactive control (button, slider, etc). */
export function isInteractiveTarget(event: Event): boolean {
const target = resolveEventTarget(event);
if (!(target instanceof Element)) return false;
return target.closest(INTERACTIVE_SELECTOR) !== null;
}