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 => {