feat: slide actions for skip, volume and brightness (#966)

This commit is contained in:
Fredrik Burmester
2025-08-21 18:02:47 +02:00
committed by GitHub
parent aac9270b62
commit 7b7aced881
12 changed files with 827 additions and 99 deletions

View File

@@ -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() {
<MediaProvider>
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
</MediaProvider>

View File

@@ -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> = ({ ...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 (
<DisabledSetting disabled={disabled} {...props}>
<ListGroup
title={t("home.settings.gesture_controls.gesture_controls_title")}
>
<ListItem
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
subtitle={t(
"home.settings.gesture_controls.horizontal_swipe_skip_description",
)}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
>
<Switch
value={settings.enableHorizontalSwipeSkip}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
onValueChange={(enableHorizontalSwipeSkip) =>
updateSettings({ enableHorizontalSwipeSkip })
}
/>
</ListItem>
<ListItem
title={t("home.settings.gesture_controls.left_side_brightness")}
subtitle={t(
"home.settings.gesture_controls.left_side_brightness_description",
)}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
>
<Switch
value={settings.enableLeftSideBrightnessSwipe}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
onValueChange={(enableLeftSideBrightnessSwipe) =>
updateSettings({ enableLeftSideBrightnessSwipe })
}
/>
</ListItem>
<ListItem
title={t("home.settings.gesture_controls.right_side_volume")}
subtitle={t(
"home.settings.gesture_controls.right_side_volume_description",
)}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
>
<Switch
value={settings.enableRightSideVolumeSwipe}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
onValueChange={(enableRightSideVolumeSwipe) =>
updateSettings({ enableRightSideVolumeSwipe })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
};

View File

@@ -20,6 +20,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const volume = useSharedValue<number>(50); // Explicitly type as number
const min = useSharedValue<number>(0); // Explicitly type as number
const max = useSharedValue<number>(100); // Explicitly type as number
const isUserInteracting = useRef(false);
const timeoutRef = useRef<number | null>(null); // Use a ref to store the timeout ID
@@ -45,18 +46,33 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ 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<AudioSliderProps> = ({ setVisibility }) => {
};
}, [isTv, volume, setVisibility]);
if (isTv) return;
if (isTv) return null;
return (
<View style={styles.sliderContainer}>

View File

@@ -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<number>(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 (
<View style={styles.sliderContainer}>

View File

@@ -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<Props> = ({
/>
) : (
<>
<VideoTouchOverlay
<GestureOverlay
screenWidth={screenWidth}
screenHeight={screenHeight}
showControls={showControls}
onToggleControls={toggleControls}
onSkipForward={handleSkipForward}
onSkipBackward={handleSkipBackward}
/>
<Animated.View
style={headerAnimatedStyle}

View File

@@ -0,0 +1,335 @@
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useRef, useState } from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useHaptic } from "@/hooks/useHaptic";
import { useSettings } from "@/utils/atoms/settings";
import { useGestureDetection } from "./hooks/useGestureDetection";
import { useVolumeAndBrightness } from "./hooks/useVolumeAndBrightness";
interface Props {
screenWidth: number;
screenHeight: number;
showControls: boolean;
onToggleControls: () => 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<FeedbackState>({
visible: false,
icon: "",
text: "",
});
const [fadeAnim] = useState(new Animated.Value(0));
const isDraggingRef = useRef(false);
const hideTimeoutRef = useRef<number | null>(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 (
<Pressable
onPress={onToggleControls}
style={{
position: "absolute",
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.75,
}}
/>
);
}
return (
<>
{/* Gesture detection area */}
<Pressable
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{
position: "absolute",
width: screenWidth,
height: screenHeight,
backgroundColor: "transparent",
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
/>
{/* Feedback overlay */}
{feedback.visible && (
<Animated.View
style={{
position: "absolute",
top: "50%",
left:
feedback.side === "left"
? "20%"
: feedback.side === "right"
? "80%"
: "50%",
transform: [
{ translateY: -25 },
{
translateX:
feedback.side === "right"
? -50
: feedback.side === "left"
? 0
: -50,
},
],
backgroundColor: "rgba(0, 0, 0, 0.8)",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
flexDirection: "row",
alignItems: "center",
opacity: fadeAnim,
zIndex: 20,
}}
>
<Ionicons
name={feedback.icon as any}
size={24}
color='white'
style={{ marginRight: 8 }}
/>
<Text style={{ color: "white", fontSize: 16, fontWeight: "600" }}>
{feedback.text}
</Text>
</Animated.View>
)}
</>
);
};

View File

@@ -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 (
<Pressable
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{
position: "absolute",
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: showControls ? 0.75 : 0,
}}
/>
);
};

View File

@@ -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,
};
};

View File

@@ -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<void>;
showNativeVolumeUI(options: { enabled: boolean }): void;
}
// Brightness type
interface Brightness {
getBrightnessAsync(): Promise<number>;
setBrightnessAsync(brightness: number): Promise<void>;
}
// 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<number | null>(null);
const initialBrightness = useRef<number | null>(null);
const dragStartY = useRef<number | null>(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,
};
};

View File

@@ -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,
};
};

View File

@@ -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",

View File

@@ -167,6 +167,10 @@ export type Settings = {
defaultPlayer: VideoPlayer;
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number;
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
enableRightSideVolumeSwipe: boolean;
};
export interface Lockable<T> {
@@ -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<Settings> => {