From 7b7aced8811795a3ac92795f98877a4a21c9948b Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 21 Aug 2025 18:02:47 +0200 Subject: [PATCH] feat: slide actions for skip, volume and brightness (#966) --- app/(auth)/(tabs)/(home)/settings.tsx | 2 + components/settings/GestureControls.tsx | 83 +++++ .../video-player/controls/AudioSlider.tsx | 22 +- .../controls/BrightnessSlider.tsx | 53 ++- components/video-player/controls/Controls.tsx | 6 +- .../video-player/controls/GestureOverlay.tsx | 335 ++++++++++++++++++ .../controls/VideoTouchOverlay.tsx | 38 -- .../controls/hooks/useGestureDetection.ts | 182 ++++++++++ .../controls/hooks/useVolumeAndBrightness.ts | 140 ++++++++ .../video-player/controls/useTapDetection.tsx | 48 --- translations/en.json | 9 + utils/atoms/settings.ts | 8 + 12 files changed, 827 insertions(+), 99 deletions(-) create mode 100644 components/settings/GestureControls.tsx create mode 100644 components/video-player/controls/GestureOverlay.tsx delete mode 100644 components/video-player/controls/VideoTouchOverlay.tsx create mode 100644 components/video-player/controls/hooks/useGestureDetection.ts create mode 100644 components/video-player/controls/hooks/useVolumeAndBrightness.ts delete mode 100644 components/video-player/controls/useTapDetection.tsx diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 5b22ba19..91f569df 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -11,6 +11,7 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; import { AudioToggles } from "@/components/settings/AudioToggles"; import { ChromecastSettings } from "@/components/settings/ChromecastSettings"; import DownloadSettings from "@/components/settings/DownloadSettings"; +import { GestureControls } from "@/components/settings/GestureControls"; import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaToggles } from "@/components/settings/MediaToggles"; import { OtherSettings } from "@/components/settings/OtherSettings"; @@ -67,6 +68,7 @@ export default function settings() { + diff --git a/components/settings/GestureControls.tsx b/components/settings/GestureControls.tsx new file mode 100644 index 00000000..217968f5 --- /dev/null +++ b/components/settings/GestureControls.tsx @@ -0,0 +1,83 @@ +import type React from "react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { ViewProps } from "react-native"; +import { Switch } from "react-native"; +import DisabledSetting from "@/components/settings/DisabledSetting"; +import { useSettings } from "@/utils/atoms/settings"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; + +interface Props extends ViewProps {} + +export const GestureControls: React.FC = ({ ...props }) => { + const { t } = useTranslation(); + + const [settings, updateSettings, pluginSettings] = useSettings(); + + const disabled = useMemo( + () => + pluginSettings?.enableHorizontalSwipeSkip?.locked === true && + pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true && + pluginSettings?.enableRightSideVolumeSwipe?.locked === true, + [pluginSettings], + ); + + if (!settings) return null; + + return ( + + + + + updateSettings({ enableHorizontalSwipeSkip }) + } + /> + + + + + updateSettings({ enableLeftSideBrightnessSwipe }) + } + /> + + + + + updateSettings({ enableRightSideVolumeSwipe }) + } + /> + + + + ); +}; diff --git a/components/video-player/controls/AudioSlider.tsx b/components/video-player/controls/AudioSlider.tsx index 9c65624e..fbb0f56e 100644 --- a/components/video-player/controls/AudioSlider.tsx +++ b/components/video-player/controls/AudioSlider.tsx @@ -20,6 +20,7 @@ const AudioSlider: React.FC = ({ setVisibility }) => { const volume = useSharedValue(50); // Explicitly type as number const min = useSharedValue(0); // Explicitly type as number const max = useSharedValue(100); // Explicitly type as number + const isUserInteracting = useRef(false); const timeoutRef = useRef(null); // Use a ref to store the timeout ID @@ -45,18 +46,33 @@ const AudioSlider: React.FC = ({ setVisibility }) => { }, [isTv, volume]); const handleValueChange = async (value: number) => { + isUserInteracting.current = true; volume.value = value; - // await VolumeManager.setVolume(value / 100); + + try { + await VolumeManager.setVolume(value / 100); + } catch (error) { + console.error("Error setting volume:", error); + } // Re-call showNativeVolumeUI to ensure the setting is applied on iOS VolumeManager.showNativeVolumeUI({ enabled: false }); + + // Reset interaction flag after a delay + setTimeout(() => { + isUserInteracting.current = false; + }, 100); }; useEffect(() => { if (isTv) return; const _volumeListener = VolumeManager.addVolumeListener( (result: VolumeResult) => { - volume.value = result.volume * 100; + // Only update if user is not currently interacting with the slider + if (!isUserInteracting.current) { + volume.value = result.volume * 100; + } + setVisibility(true); // Clear any existing timeout @@ -79,7 +95,7 @@ const AudioSlider: React.FC = ({ setVisibility }) => { }; }, [isTv, volume, setVisibility]); - if (isTv) return; + if (isTv) return null; return ( diff --git a/components/video-player/controls/BrightnessSlider.tsx b/components/video-player/controls/BrightnessSlider.tsx index 330bb3fc..db92bd10 100644 --- a/components/video-player/controls/BrightnessSlider.tsx +++ b/components/video-player/controls/BrightnessSlider.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { Platform, StyleSheet, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { useSharedValue } from "react-native-reanimated"; @@ -14,22 +14,59 @@ const BrightnessSlider = () => { const brightness = useSharedValue(50); const min = useSharedValue(0); const max = useSharedValue(100); + const isUserInteracting = useRef(false); + const lastKnownBrightness = useRef(50); + + // Update brightness from device + const updateBrightnessFromDevice = async () => { + if (isTv || !Brightness || isUserInteracting.current) return; + + try { + const currentBrightness = await Brightness.getBrightnessAsync(); + const brightnessPercent = Math.round(currentBrightness * 100); + + // Only update if brightness actually changed + if (Math.abs(brightnessPercent - lastKnownBrightness.current) > 1) { + brightness.value = brightnessPercent; + lastKnownBrightness.current = brightnessPercent; + } + } catch (error) { + console.error("Error fetching brightness:", error); + } + }; useEffect(() => { if (isTv) return; - const fetchInitialBrightness = async () => { - const initialBrightness = await Brightness.getBrightnessAsync(); - brightness.value = initialBrightness * 100; + + // Initial brightness fetch + updateBrightnessFromDevice(); + + // Set up periodic brightness checking to sync with gesture changes + const interval = setInterval(updateBrightnessFromDevice, 200); // Check every 200ms + + return () => { + clearInterval(interval); }; - fetchInitialBrightness(); - }, [brightness, isTv]); + }, [isTv]); const handleValueChange = async (value: number) => { + isUserInteracting.current = true; brightness.value = value; - await Brightness.setBrightnessAsync(value / 100); + lastKnownBrightness.current = value; + + try { + await Brightness.setBrightnessAsync(value / 100); + } catch (error) { + console.error("Error setting brightness:", error); + } + + // Reset interaction flag after a delay + setTimeout(() => { + isUserInteracting.current = false; + }, 100); }; - if (isTv) return; + if (isTv) return null; return ( diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 59e80d2c..65880958 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -36,6 +36,7 @@ import { CenterControls } from "./CenterControls"; import { CONTROLS_CONSTANTS } from "./constants"; import { ControlProvider } from "./contexts/ControlContext"; import { EpisodeList } from "./EpisodeList"; +import { GestureOverlay } from "./GestureOverlay"; import { HeaderControls } from "./HeaderControls"; import { useRemoteControl } from "./hooks/useRemoteControl"; import { useVideoNavigation } from "./hooks/useVideoNavigation"; @@ -44,7 +45,6 @@ import { useVideoTime } from "./hooks/useVideoTime"; import { type ScaleFactor } from "./ScaleFactorSelector"; import { useControlsTimeout } from "./useControlsTimeout"; import { type AspectRatio } from "./VideoScalingModeSelector"; -import { VideoTouchOverlay } from "./VideoTouchOverlay"; interface Props { item: BaseItemDto; @@ -483,11 +483,13 @@ export const Controls: FC = ({ /> ) : ( <> - void; + onSkipForward: () => void; + onSkipBackward: () => void; +} + +interface FeedbackState { + visible: boolean; + icon: string; + text: string; + side?: "left" | "right"; +} + +export const GestureOverlay = ({ + screenWidth, + screenHeight, + showControls, + onToggleControls, + onSkipForward, + onSkipBackward, +}: Props) => { + const [settings] = useSettings(); + const lightHaptic = useHaptic("light"); + + const [feedback, setFeedback] = useState({ + visible: false, + icon: "", + text: "", + }); + const [fadeAnim] = useState(new Animated.Value(0)); + const isDraggingRef = useRef(false); + const hideTimeoutRef = useRef(null); + const lastUpdateTime = useRef(0); + + const showFeedback = useCallback( + ( + icon: string, + text: string, + side?: "left" | "right", + isDuringDrag = false, + ) => { + // Clear any existing timeout + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + + // Defer ALL state updates to avoid useInsertionEffect warning + requestAnimationFrame(() => { + setFeedback({ visible: true, icon, text, side }); + + if (!isDuringDrag) { + // For discrete actions (like skip), show normal animation + Animated.sequence([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + Animated.delay(1000), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + ]).start(() => { + requestAnimationFrame(() => { + setFeedback((prev) => ({ ...prev, visible: false })); + }); + }); + } else if (!isDraggingRef.current) { + // For drag start, just fade in and stay visible + isDraggingRef.current = true; + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }).start(); + } + // For drag updates, just update the state, don't restart animation + }); + }, + [fadeAnim], + ); + + const hideDragFeedback = useCallback(() => { + isDraggingRef.current = false; + + // Delay hiding slightly to avoid flicker + hideTimeoutRef.current = setTimeout(() => { + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(() => { + requestAnimationFrame(() => { + setFeedback((prev) => ({ ...prev, visible: false })); + }); + }); + }, 100) as unknown as number; + }, [fadeAnim]); + + const { + startVolumeDrag, + updateVolumeDrag, + endVolumeDrag, + startBrightnessDrag, + updateBrightnessDrag, + endBrightnessDrag, + } = useVolumeAndBrightness({ + onVolumeChange: (volume: number) => { + // Throttle feedback updates during dragging to reduce callback frequency + const now = Date.now(); + if (now - lastUpdateTime.current < 50) return; // 50ms throttle + lastUpdateTime.current = now; + + // Defer feedback update to avoid useInsertionEffect warning + requestAnimationFrame(() => { + showFeedback("volume-high", `${volume}%`, "right", true); + }); + }, + onBrightnessChange: (brightness: number) => { + // Throttle feedback updates during dragging to reduce callback frequency + const now = Date.now(); + if (now - lastUpdateTime.current < 50) return; // 50ms throttle + lastUpdateTime.current = now; + + // Defer feedback update to avoid useInsertionEffect warning + requestAnimationFrame(() => { + showFeedback("sunny", `${brightness}%`, "left", true); + }); + }, + }); + + const handleSkipForward = useCallback(() => { + if (!settings.enableHorizontalSwipeSkip) return; + lightHaptic(); + // Defer all actions to avoid useInsertionEffect warning + requestAnimationFrame(() => { + onSkipForward(); + showFeedback("play-forward", `+${settings.forwardSkipTime}s`); + }); + }, [ + settings.enableHorizontalSwipeSkip, + settings.forwardSkipTime, + lightHaptic, + onSkipForward, + showFeedback, + ]); + + const handleSkipBackward = useCallback(() => { + if (!settings.enableHorizontalSwipeSkip) return; + lightHaptic(); + // Defer all actions to avoid useInsertionEffect warning + requestAnimationFrame(() => { + onSkipBackward(); + showFeedback("play-back", `-${settings.rewindSkipTime}s`); + }); + }, [ + settings.enableHorizontalSwipeSkip, + settings.rewindSkipTime, + lightHaptic, + onSkipBackward, + showFeedback, + ]); + + const handleVerticalDragStart = useCallback( + (side: "left" | "right", startY: number) => { + if (side === "left" && settings.enableLeftSideBrightnessSwipe) { + lightHaptic(); + // Defer drag start to avoid useInsertionEffect warning + requestAnimationFrame(() => { + startBrightnessDrag(startY); + }); + } else if (side === "right" && settings.enableRightSideVolumeSwipe) { + lightHaptic(); + // Defer drag start to avoid useInsertionEffect warning + requestAnimationFrame(() => { + startVolumeDrag(startY); + }); + } + }, + [ + settings.enableLeftSideBrightnessSwipe, + settings.enableRightSideVolumeSwipe, + lightHaptic, + startBrightnessDrag, + startVolumeDrag, + ], + ); + + const handleVerticalDragMove = useCallback( + (side: "left" | "right", deltaY: number) => { + // Use requestAnimationFrame to defer drag move updates too + requestAnimationFrame(() => { + if (side === "left" && settings.enableLeftSideBrightnessSwipe) { + updateBrightnessDrag(deltaY); + } else if (side === "right" && settings.enableRightSideVolumeSwipe) { + updateVolumeDrag(deltaY); + } + }); + }, + [ + settings.enableLeftSideBrightnessSwipe, + settings.enableRightSideVolumeSwipe, + updateBrightnessDrag, + updateVolumeDrag, + ], + ); + + const handleVerticalDragEnd = useCallback( + (side: "left" | "right") => { + // Defer drag end to avoid useInsertionEffect warning + requestAnimationFrame(() => { + if (side === "left") { + endBrightnessDrag(); + } else { + endVolumeDrag(); + } + hideDragFeedback(); + }); + }, + [endBrightnessDrag, endVolumeDrag, hideDragFeedback], + ); + + const { handleTouchStart, handleTouchMove, handleTouchEnd } = + useGestureDetection({ + onSwipeLeft: handleSkipBackward, + onSwipeRight: handleSkipForward, + onVerticalDragStart: handleVerticalDragStart, + onVerticalDragMove: handleVerticalDragMove, + onVerticalDragEnd: handleVerticalDragEnd, + onTap: onToggleControls, + screenWidth, + screenHeight, + }); + + // If controls are visible, act like the old tap overlay + if (showControls) { + return ( + + ); + } + + return ( + <> + {/* Gesture detection area */} + + + {/* Feedback overlay */} + {feedback.visible && ( + + + + {feedback.text} + + + )} + + ); +}; diff --git a/components/video-player/controls/VideoTouchOverlay.tsx b/components/video-player/controls/VideoTouchOverlay.tsx deleted file mode 100644 index 85385acf..00000000 --- a/components/video-player/controls/VideoTouchOverlay.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Pressable } from "react-native"; -import { useTapDetection } from "./useTapDetection"; - -interface Props { - screenWidth: number; - screenHeight: number; - showControls: boolean; - onToggleControls: () => void; -} - -export const VideoTouchOverlay = ({ - screenWidth, - screenHeight, - showControls, - onToggleControls, -}: Props) => { - const { handleTouchStart, handleTouchEnd } = useTapDetection({ - onValidTap: onToggleControls, - }); - - return ( - - ); -}; diff --git a/components/video-player/controls/hooks/useGestureDetection.ts b/components/video-player/controls/hooks/useGestureDetection.ts new file mode 100644 index 00000000..2144783f --- /dev/null +++ b/components/video-player/controls/hooks/useGestureDetection.ts @@ -0,0 +1,182 @@ +import { useCallback, useRef } from "react"; +import type { GestureResponderEvent } from "react-native"; + +export interface SwipeGestureOptions { + minDistance?: number; + maxDuration?: number; + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + onVerticalDragStart?: (side: "left" | "right", initialY: number) => void; + onVerticalDragMove?: ( + side: "left" | "right", + deltaY: number, + currentY: number, + ) => void; + onVerticalDragEnd?: (side: "left" | "right") => void; + onTap?: () => void; + screenWidth?: number; + screenHeight?: number; +} + +export const useGestureDetection = ({ + minDistance = 50, + maxDuration = 800, + onSwipeLeft, + onSwipeRight, + onVerticalDragStart, + onVerticalDragMove, + onVerticalDragEnd, + onTap, + screenWidth = 400, +}: SwipeGestureOptions = {}) => { + const touchStartTime = useRef(0); + const touchStartPosition = useRef({ x: 0, y: 0 }); + const lastTouchPosition = useRef({ x: 0, y: 0 }); + const isDragging = useRef(false); + const dragSide = useRef<"left" | "right" | null>(null); + const hasMovedEnough = useRef(false); + const gestureType = useRef<"none" | "horizontal" | "vertical">("none"); + + const handleTouchStart = useCallback((event: GestureResponderEvent) => { + touchStartTime.current = Date.now(); + touchStartPosition.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + lastTouchPosition.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + isDragging.current = false; + dragSide.current = null; + hasMovedEnough.current = false; + gestureType.current = "none"; + }, []); + + const handleTouchMove = useCallback( + (event: GestureResponderEvent) => { + const currentPosition = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + + const deltaX = currentPosition.x - touchStartPosition.current.x; + const deltaY = currentPosition.y - touchStartPosition.current.y; + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + const totalDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // Lower threshold for starting gestures - make it more sensitive + if (!hasMovedEnough.current && totalDistance > 8) { + hasMovedEnough.current = true; + + // Determine gesture type based on initial movement direction + if (absY > absX && absY > 5) { + // Vertical gesture - start drag immediately + gestureType.current = "vertical"; + const side = + touchStartPosition.current.x < screenWidth / 2 ? "left" : "right"; + 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"; + } + } + + // Continue vertical drag if already dragging + if ( + isDragging.current && + dragSide.current && + gestureType.current === "vertical" + ) { + const deltaFromStart = currentPosition.y - touchStartPosition.current.y; + onVerticalDragMove?.( + dragSide.current, + deltaFromStart, + currentPosition.y, + ); + } + + lastTouchPosition.current = currentPosition; + }, + [onVerticalDragStart, onVerticalDragMove, screenWidth], + ); + + const handleTouchEnd = useCallback( + (event: GestureResponderEvent) => { + const touchEndTime = Date.now(); + const touchEndPosition = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + + 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 totalDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // End vertical drag if we were dragging + if ( + isDragging.current && + dragSide.current && + gestureType.current === "vertical" + ) { + onVerticalDragEnd?.(dragSide.current); + isDragging.current = false; + dragSide.current = null; + hasMovedEnough.current = false; + gestureType.current = "none"; + return; + } + + // Check if gesture is too long for discrete actions + if (touchDuration > maxDuration) { + hasMovedEnough.current = false; + gestureType.current = "none"; + return; + } + + // Handle discrete horizontal swipes (for skip) only if it was marked as horizontal + 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?.(); + } + + hasMovedEnough.current = false; + gestureType.current = "none"; + }, + [ + maxDuration, + minDistance, + onSwipeLeft, + onSwipeRight, + onVerticalDragEnd, + onTap, + ], + ); + + return { + handleTouchStart, + handleTouchMove, + handleTouchEnd, + }; +}; diff --git a/components/video-player/controls/hooks/useVolumeAndBrightness.ts b/components/video-player/controls/hooks/useVolumeAndBrightness.ts new file mode 100644 index 00000000..2863949b --- /dev/null +++ b/components/video-player/controls/hooks/useVolumeAndBrightness.ts @@ -0,0 +1,140 @@ +import { useCallback, useRef } from "react"; +import { Platform } from "react-native"; + +// Volume Manager type (since it's not exported from the library) +interface VolumeManager { + getVolume(): Promise<{ volume: number }>; + setVolume(volume: number): Promise; + showNativeVolumeUI(options: { enabled: boolean }): void; +} + +// Brightness type +interface Brightness { + getBrightnessAsync(): Promise; + setBrightnessAsync(brightness: number): Promise; +} + +// Dynamic imports for TV compatibility +const VolumeManager: VolumeManager | null = !Platform.isTV + ? require("react-native-volume-manager") + : null; +const Brightness: Brightness | null = !Platform.isTV + ? require("expo-brightness") + : null; + +interface UseVolumeAndBrightnessOptions { + onVolumeChange?: (volume: number) => void; + onBrightnessChange?: (brightness: number) => void; +} + +export const useVolumeAndBrightness = ({ + onVolumeChange, + onBrightnessChange, +}: UseVolumeAndBrightnessOptions = {}) => { + const initialVolume = useRef(null); + const initialBrightness = useRef(null); + const dragStartY = useRef(null); + + const startVolumeDrag = useCallback(async (startY: number) => { + if (Platform.isTV || !VolumeManager) return; + + try { + const { volume } = await VolumeManager.getVolume(); + initialVolume.current = volume; + dragStartY.current = startY; + + // Disable native volume UI during drag + VolumeManager.showNativeVolumeUI({ enabled: false }); + } catch (error) { + console.error("Error starting volume drag:", error); + } + }, []); + + const updateVolumeDrag = useCallback( + async (deltaY: number) => { + if (Platform.isTV || !VolumeManager || initialVolume.current === null) + return; + + try { + // Convert deltaY to volume change (negative deltaY = volume up) + // More sensitive - easier to control + const sensitivity = 0.006; // Doubled sensitivity for easier control + const volumeChange = -deltaY * sensitivity; + const newVolume = Math.max( + 0, + Math.min(1, initialVolume.current + volumeChange), + ); + + await VolumeManager.setVolume(newVolume); + const volumePercent = Math.round(newVolume * 100); + onVolumeChange?.(volumePercent); + } catch (error) { + console.error("Error updating volume:", error); + } + }, + [onVolumeChange], + ); + + const endVolumeDrag = useCallback(() => { + if (Platform.isTV || !VolumeManager) return; + + // Re-enable native volume UI + setTimeout(() => { + VolumeManager.showNativeVolumeUI({ enabled: true }); + }, 500); + + initialVolume.current = null; + dragStartY.current = null; + }, []); + + const startBrightnessDrag = useCallback(async (startY: number) => { + if (Platform.isTV || !Brightness) return; + + try { + const brightness = await Brightness.getBrightnessAsync(); + initialBrightness.current = brightness; + dragStartY.current = startY; + } catch (error) { + console.error("Error starting brightness drag:", error); + } + }, []); + + const updateBrightnessDrag = useCallback( + async (deltaY: number) => { + if (Platform.isTV || !Brightness || initialBrightness.current === null) + return; + + try { + // Convert deltaY to brightness change (negative deltaY = brightness up) + // More sensitive - easier to control + const sensitivity = 0.004; // Doubled sensitivity for easier control + const brightnessChange = -deltaY * sensitivity; + const newBrightness = Math.max( + 0, + Math.min(1, initialBrightness.current + brightnessChange), + ); + + await Brightness.setBrightnessAsync(newBrightness); + const brightnessPercent = Math.round(newBrightness * 100); + onBrightnessChange?.(brightnessPercent); + } catch (error) { + console.error("Error updating brightness:", error); + } + }, + [onBrightnessChange], + ); + + const endBrightnessDrag = useCallback(() => { + initialBrightness.current = null; + dragStartY.current = null; + }, []); + + return { + startVolumeDrag, + updateVolumeDrag, + endVolumeDrag, + startBrightnessDrag, + updateBrightnessDrag, + endBrightnessDrag, + }; +}; diff --git a/components/video-player/controls/useTapDetection.tsx b/components/video-player/controls/useTapDetection.tsx deleted file mode 100644 index f9d5e4f1..00000000 --- a/components/video-player/controls/useTapDetection.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useRef } from "react"; -import type { GestureResponderEvent } from "react-native"; - -interface TapDetectionOptions { - maxDuration?: number; - maxDistance?: number; - onValidTap?: () => void; -} - -export const useTapDetection = ({ - maxDuration = 200, - maxDistance = 10, - onValidTap, -}: TapDetectionOptions = {}) => { - const touchStartTime = useRef(0); - const touchStartPosition = useRef({ x: 0, y: 0 }); - - const handleTouchStart = (event: GestureResponderEvent) => { - touchStartTime.current = Date.now(); - touchStartPosition.current = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - }; - }; - - const handleTouchEnd = (event: GestureResponderEvent) => { - const touchEndTime = Date.now(); - const touchEndPosition = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - }; - - const touchDuration = touchEndTime - touchStartTime.current; - const touchDistance = Math.sqrt( - (touchEndPosition.x - touchStartPosition.current.x) ** 2 + - (touchEndPosition.y - touchStartPosition.current.y) ** 2, - ); - - if (touchDuration < maxDuration && touchDistance < maxDistance) { - onValidTap?.(); - } - }; - - return { - handleTouchStart, - handleTouchEnd, - }; -}; diff --git a/translations/en.json b/translations/en.json index ffbb8f41..a03adb74 100644 --- a/translations/en.json +++ b/translations/en.json @@ -85,6 +85,15 @@ "rewind_length": "Rewind length", "seconds_unit": "s" }, + "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", + "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", + "right_side_volume_description": "Swipe up/down on right side to adjust volume" + }, "audio": { "audio_title": "Audio", "set_audio_track": "Set Audio Track From Previous Item", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 39b1ed27..2c1746f5 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -167,6 +167,10 @@ export type Settings = { defaultPlayer: VideoPlayer; maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount; autoPlayEpisodeCount: number; + // Gesture controls + enableHorizontalSwipeSkip: boolean; + enableLeftSideBrightnessSwipe: boolean; + enableRightSideVolumeSwipe: boolean; }; export interface Lockable { @@ -223,6 +227,10 @@ const defaultValues: Settings = { defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android maxAutoPlayEpisodeCount: { key: "3", value: 3 }, autoPlayEpisodeCount: 0, + // Gesture controls + enableHorizontalSwipeSkip: true, + enableLeftSideBrightnessSwipe: true, + enableRightSideVolumeSwipe: true, }; const loadSettings = (): Partial => {