diff --git a/packages/core/src/dom/gesture/coordinator.ts b/packages/core/src/dom/gesture/coordinator.ts
index f03beb1a..d8d8c6d9 100644
--- a/packages/core/src/dom/gesture/coordinator.ts
+++ b/packages/core/src/dom/gesture/coordinator.ts
@@ -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;
diff --git a/packages/core/src/dom/gesture/create-tap-gesture.ts b/packages/core/src/dom/gesture/create-tap-gesture.ts
index fd6eba79..2ea9235a 100644
--- a/packages/core/src/dom/gesture/create-tap-gesture.ts
+++ b/packages/core/src/dom/gesture/create-tap-gesture.ts
@@ -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,
});
}
diff --git a/packages/core/src/dom/gesture/gesture.ts b/packages/core/src/dom/gesture/gesture.ts
index 2c9bd25b..eef96f63 100644
--- a/packages/core/src/dom/gesture/gesture.ts
+++ b/packages/core/src/dom/gesture/gesture.ts
@@ -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 {
diff --git a/packages/core/src/dom/store/features/controls.ts b/packages/core/src/dom/store/features/controls.ts
index 05740bb1..838d4d65 100644
--- a/packages/core/src/dom/store/features/controls.ts
+++ b/packages/core/src/dom/store/features/controls.ts
@@ -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();
diff --git a/packages/html/src/define/audio/ui.ts b/packages/html/src/define/audio/ui.ts
index 8747d81e..9b1cd07d 100644
--- a/packages/html/src/define/audio/ui.ts
+++ b/packages/html/src/define/audio/ui.ts
@@ -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);
diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts
index a34298bd..a914f199 100644
--- a/packages/html/src/define/video/skin.ts
+++ b/packages/html/src/define/video/skin.ts
@@ -149,6 +149,13 @@ function getTemplateHTML() {
+
+
+
+
+
+
+
`;
}
diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts
index 2ac60bdf..70c7f1ed 100644
--- a/packages/html/src/define/video/ui.ts
+++ b/packages/html/src/define/video/ui.ts
@@ -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);
diff --git a/packages/html/src/ui/gesture/gesture-element.ts b/packages/html/src/ui/gesture/gesture-element.ts
index bfe9297c..307a5971 100644
--- a/packages/html/src/ui/gesture/gesture-element.ts
+++ b/packages/html/src/ui/gesture/gesture-element.ts
@@ -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') {
diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx
index ffd6fc9d..a0208d04 100644
--- a/packages/react/src/presets/video/skin.tsx
+++ b/packages/react/src/presets/video/skin.tsx
@@ -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 {
+
+ {/* Gestures */}
+
+
+
+
+
);
}
diff --git a/packages/react/src/ui/gesture/media-gesture.tsx b/packages/react/src/ui/gesture/media-gesture.tsx
index 659797fb..eb8a7572 100644
--- a/packages/react/src/ui/gesture/media-gesture.tsx
+++ b/packages/react/src/ui/gesture/media-gesture.tsx
@@ -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);
diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts
index 8f3e56ab..54cd3c9b 100644
--- a/packages/utils/src/dom/index.ts
+++ b/packages/utils/src/dom/index.ts
@@ -12,6 +12,7 @@ export {
isHTMLAudioElement,
isHTMLMediaElement,
isHTMLVideoElement,
+ isInteractiveTarget,
} from './predicates';
export { type RafThrottled, rafThrottle } from './raf-throttle';
export {
diff --git a/packages/utils/src/dom/predicates.ts b/packages/utils/src/dom/predicates.ts
index f96e7083..0f18301a 100644
--- a/packages/utils/src/dom/predicates.ts
+++ b/packages/utils/src/dom/predicates.ts
@@ -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;
+}