mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
feat: slide actions for skip, volume and brightness (#966)
This commit is contained in:
committed by
GitHub
parent
aac9270b62
commit
7b7aced881
@@ -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>
|
||||
|
||||
83
components/settings/GestureControls.tsx
Normal file
83
components/settings/GestureControls.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
335
components/video-player/controls/GestureOverlay.tsx
Normal file
335
components/video-player/controls/GestureOverlay.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
182
components/video-player/controls/hooks/useGestureDetection.ts
Normal file
182
components/video-player/controls/hooks/useGestureDetection.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
140
components/video-player/controls/hooks/useVolumeAndBrightness.ts
Normal file
140
components/video-player/controls/hooks/useVolumeAndBrightness.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user