mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
337 lines
9.4 KiB
TypeScript
337 lines
9.4 KiB
TypeScript
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.enableDoubleTapSkip) return;
|
|
lightHaptic();
|
|
// Defer all actions to avoid useInsertionEffect warning
|
|
requestAnimationFrame(() => {
|
|
onSkipForward();
|
|
showFeedback("play-forward", `+${settings.forwardSkipTime}s`);
|
|
});
|
|
}, [
|
|
settings.enableDoubleTapSkip,
|
|
settings.forwardSkipTime,
|
|
lightHaptic,
|
|
onSkipForward,
|
|
showFeedback,
|
|
]);
|
|
|
|
const handleSkipBackward = useCallback(() => {
|
|
if (!settings.enableDoubleTapSkip) return;
|
|
lightHaptic();
|
|
// Defer all actions to avoid useInsertionEffect warning
|
|
requestAnimationFrame(() => {
|
|
onSkipBackward();
|
|
showFeedback("play-back", `-${settings.rewindSkipTime}s`);
|
|
});
|
|
}, [
|
|
settings.enableDoubleTapSkip,
|
|
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({
|
|
onDoubleTapLeft: handleSkipBackward,
|
|
onDoubleTapRight: handleSkipForward,
|
|
onVerticalDragStart: handleVerticalDragStart,
|
|
onVerticalDragMove: handleVerticalDragMove,
|
|
onVerticalDragEnd: handleVerticalDragEnd,
|
|
onTap: onToggleControls,
|
|
screenWidth,
|
|
screenHeight,
|
|
});
|
|
|
|
// Background overlay when controls are visible
|
|
const controlsOverlay = showControls && (
|
|
<Pressable
|
|
onPress={onToggleControls}
|
|
style={{
|
|
position: "absolute",
|
|
width: screenWidth,
|
|
height: screenHeight,
|
|
backgroundColor: "black",
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
opacity: 0.75,
|
|
}}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* Controls overlay when visible */}
|
|
{controlsOverlay}
|
|
|
|
{/* Gesture detection area - always present */}
|
|
<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>
|
|
)}
|
|
</>
|
|
);
|
|
};
|