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; +}