From cd7a7b0e0e4bf50a859fbcfbf2b7eb48713038fc Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 16 Nov 2025 22:13:35 +0100 Subject: [PATCH] feat: double tap to seek --- components/settings/GestureControls.tsx | 12 ++-- .../video-player/controls/GestureOverlay.tsx | 12 ++-- .../controls/hooks/useGestureDetection.ts | 68 +++++++++++++------ translations/ar.json | 4 +- translations/en.json | 4 +- translations/hu.json | 4 +- utils/atoms/settings.ts | 4 +- 7 files changed, 67 insertions(+), 41 deletions(-) diff --git a/components/settings/GestureControls.tsx b/components/settings/GestureControls.tsx index 55aadaa1..bc73e20d 100644 --- a/components/settings/GestureControls.tsx +++ b/components/settings/GestureControls.tsx @@ -17,7 +17,7 @@ export const GestureControls: React.FC = ({ ...props }) => { const disabled = useMemo( () => - pluginSettings?.enableHorizontalSwipeSkip?.locked === true && + pluginSettings?.enableDoubleTapSkip?.locked === true && pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true && pluginSettings?.enableRightSideVolumeSwipe?.locked === true, [pluginSettings], @@ -35,13 +35,13 @@ export const GestureControls: React.FC = ({ ...props }) => { subtitle={t( "home.settings.gesture_controls.horizontal_swipe_skip_description", )} - disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked} + disabled={pluginSettings?.enableDoubleTapSkip?.locked} > - updateSettings({ enableHorizontalSwipeSkip }) + value={settings.enableDoubleTapSkip} + disabled={pluginSettings?.enableDoubleTapSkip?.locked} + onValueChange={(enableDoubleTapSkip) => + updateSettings({ enableDoubleTapSkip }) } /> diff --git a/components/video-player/controls/GestureOverlay.tsx b/components/video-player/controls/GestureOverlay.tsx index e4ca20e6..26a2e26f 100644 --- a/components/video-player/controls/GestureOverlay.tsx +++ b/components/video-player/controls/GestureOverlay.tsx @@ -145,7 +145,7 @@ export const GestureOverlay = ({ }); const handleSkipForward = useCallback(() => { - if (!settings.enableHorizontalSwipeSkip) return; + if (!settings.enableDoubleTapSkip) return; lightHaptic(); // Defer all actions to avoid useInsertionEffect warning requestAnimationFrame(() => { @@ -153,7 +153,7 @@ export const GestureOverlay = ({ showFeedback("play-forward", `+${settings.forwardSkipTime}s`); }); }, [ - settings.enableHorizontalSwipeSkip, + settings.enableDoubleTapSkip, settings.forwardSkipTime, lightHaptic, onSkipForward, @@ -161,7 +161,7 @@ export const GestureOverlay = ({ ]); const handleSkipBackward = useCallback(() => { - if (!settings.enableHorizontalSwipeSkip) return; + if (!settings.enableDoubleTapSkip) return; lightHaptic(); // Defer all actions to avoid useInsertionEffect warning requestAnimationFrame(() => { @@ -169,7 +169,7 @@ export const GestureOverlay = ({ showFeedback("play-back", `-${settings.rewindSkipTime}s`); }); }, [ - settings.enableHorizontalSwipeSkip, + settings.enableDoubleTapSkip, settings.rewindSkipTime, lightHaptic, onSkipBackward, @@ -237,8 +237,8 @@ export const GestureOverlay = ({ const { handleTouchStart, handleTouchMove, handleTouchEnd } = useGestureDetection({ - onSwipeLeft: handleSkipBackward, - onSwipeRight: handleSkipForward, + onDoubleTapLeft: handleSkipBackward, + onDoubleTapRight: handleSkipForward, onVerticalDragStart: handleVerticalDragStart, onVerticalDragMove: handleVerticalDragMove, onVerticalDragEnd: handleVerticalDragEnd, diff --git a/components/video-player/controls/hooks/useGestureDetection.ts b/components/video-player/controls/hooks/useGestureDetection.ts index c66df6d0..f94243ed 100644 --- a/components/video-player/controls/hooks/useGestureDetection.ts +++ b/components/video-player/controls/hooks/useGestureDetection.ts @@ -6,6 +6,8 @@ export interface SwipeGestureOptions { maxDuration?: number; onSwipeLeft?: () => void; onSwipeRight?: () => void; + onDoubleTapLeft?: () => void; + onDoubleTapRight?: () => void; onVerticalDragStart?: (side: "left" | "right", initialY: number) => void; onVerticalDragMove?: ( side: "left" | "right", @@ -23,6 +25,8 @@ export const useGestureDetection = ({ maxDuration = 800, onSwipeLeft, onSwipeRight, + onDoubleTapLeft, + onDoubleTapRight, onVerticalDragStart, onVerticalDragMove, onVerticalDragEnd, @@ -39,6 +43,11 @@ export const useGestureDetection = ({ const gestureType = useRef<"none" | "horizontal" | "vertical">("none"); const shouldIgnoreTouch = useRef(false); + // Double tap detection refs + const lastTapTime = useRef(0); + const lastTapPosition = useRef({ x: 0, y: 0 }); + const doubleTapTimeWindow = 300; // 300ms window for double tap + const handleTouchStart = useCallback( (event: GestureResponderEvent) => { const startY = event.nativeEvent.pageY; @@ -102,9 +111,6 @@ export const useGestureDetection = ({ isDragging.current = true; dragSide.current = side; onVerticalDragStart?.(side, touchStartPosition.current.y); - } else if (absX > absY && absX > 10) { - // Horizontal gesture - mark for discrete swipe - gestureType.current = "horizontal"; } } @@ -144,8 +150,8 @@ export const useGestureDetection = ({ const touchDuration = touchEndTime - touchStartTime.current; const deltaX = touchEndPosition.x - touchStartPosition.current.x; const deltaY = touchEndPosition.y - touchStartPosition.current.y; - const absX = Math.abs(deltaX); - const absY = Math.abs(deltaY); + const _absX = Math.abs(deltaX); + const _absY = Math.abs(deltaY); const totalDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); // End vertical drag if we were dragging @@ -169,25 +175,43 @@ export const useGestureDetection = ({ return; } - // Handle discrete horizontal swipes (for skip) only if it was marked as horizontal + // Check if it's a tap (short duration and small movement) if ( - gestureType.current === "horizontal" && - hasMovedEnough.current && - absX > absY && - totalDistance > minDistance - ) { - if (deltaX > 0) { - onSwipeRight?.(); - } else { - onSwipeLeft?.(); - } - } else if ( !hasMovedEnough.current && touchDuration < 300 && totalDistance < 10 ) { - // It's a tap - short duration and small movement - onTap?.(); + const currentTime = Date.now(); + const tapX = touchEndPosition.x; + const tapY = touchEndPosition.y; + + // Check for double tap + const timeSinceLastTap = currentTime - lastTapTime.current; + const distanceFromLastTap = Math.sqrt( + (tapX - lastTapPosition.current.x) ** 2 + + (tapY - lastTapPosition.current.y) ** 2, + ); + + if ( + timeSinceLastTap <= doubleTapTimeWindow && + distanceFromLastTap < 50 + ) { + // It's a double tap + const isLeftSide = tapX < screenWidth / 2; + if (isLeftSide) { + onDoubleTapLeft?.(); + } else { + onDoubleTapRight?.(); + } + // Reset last tap to prevent triple tap + lastTapTime.current = 0; + lastTapPosition.current = { x: 0, y: 0 }; + } else { + // It's a single tap + onTap?.(); + lastTapTime.current = currentTime; + lastTapPosition.current = { x: tapX, y: tapY }; + } } hasMovedEnough.current = false; @@ -196,10 +220,12 @@ export const useGestureDetection = ({ [ maxDuration, minDistance, - onSwipeLeft, - onSwipeRight, + onDoubleTapLeft, + onDoubleTapRight, onVerticalDragEnd, onTap, + doubleTapTimeWindow, + screenWidth, ], ); diff --git a/translations/ar.json b/translations/ar.json index 248f1a70..003f581e 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -89,8 +89,8 @@ }, "gesture_controls": { "gesture_controls_title": "التحكم بالإيماءات", - "horizontal_swipe_skip": "السحب الأفقي للتخطي", - "horizontal_swipe_skip_description": "اسحب لليسار/لليمين عندما تكون عناصر التحكم مخفية للتخطي", + "horizontal_swipe_skip": "النقر المزدوج للتخطي", + "horizontal_swipe_skip_description": "انقر نقرًا مزدوجًا على الجانب الأيسر/الأيمن عندما تكون عناصر التحكم مخفية للتخطي", "left_side_brightness": "التحكم في السطوع من الجانب الأيسر", "left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع", "right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن", diff --git a/translations/en.json b/translations/en.json index e1596030..4b68775a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -106,8 +106,8 @@ }, "gesture_controls": { "gesture_controls_title": "Gesture Controls", - "horizontal_swipe_skip": "Horizontal Swipe to Skip", - "horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip", + "horizontal_swipe_skip": "Double Tap to Skip", + "horizontal_swipe_skip_description": "Double tap left/right side when controls are hidden to skip", "left_side_brightness": "Left Side Brightness Control", "left_side_brightness_description": "Swipe up/down on left side to adjust brightness", "right_side_volume": "Right Side Volume Control", diff --git a/translations/hu.json b/translations/hu.json index a80b9767..0943449c 100644 --- a/translations/hu.json +++ b/translations/hu.json @@ -94,8 +94,8 @@ }, "gesture_controls": { "gesture_controls_title": "Gesztusvezérlés", - "horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz", - "horizontal_swipe_skip_description": "Ha a vezérlők el vannak rejtve, húzd balra vagy jobbra az ugráshoz.", + "horizontal_swipe_skip": "Dupla Érintés Ugráshoz", + "horizontal_swipe_skip_description": "Dupla érintés bal/jobb oldalon ha a vezérlők el vannak rejtve az ugráshoz.", "left_side_brightness": "Fényerő a Bal Oldalon", "left_side_brightness_description": "Húzd felfelé vagy lefelé a bal oldalon a fényerő állításához", "right_side_volume": "Fényerő a Jobb Oldalon", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index ee0e8625..4a0bb48a 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -175,7 +175,7 @@ export type Settings = { vlcOutlineOpacity?: number; vlcIsBold?: boolean; // Gesture controls - enableHorizontalSwipeSkip: boolean; + enableDoubleTapSkip: boolean; enableLeftSideBrightnessSwipe: boolean; enableRightSideVolumeSwipe: boolean; usePopularPlugin: boolean; @@ -239,7 +239,7 @@ export const defaultValues: Settings = { vlcOutlineOpacity: undefined, vlcIsBold: undefined, // Gesture controls - enableHorizontalSwipeSkip: true, + enableDoubleTapSkip: true, enableLeftSideBrightnessSwipe: true, enableRightSideVolumeSwipe: true, usePopularPlugin: true,