import { View, Text, Pressable, GestureResponderEvent, Dimensions, } from 'react-native'; import React, {useState, useRef, useEffect, useCallback, useMemo} from 'react'; import Animated, { useAnimatedStyle, withTiming, withSequence, useSharedValue, withDelay, runOnJS, } from 'react-native-reanimated'; import Icon from '@expo/vector-icons/MaterialIcons'; import * as Brightness from 'expo-brightness'; import {VolumeManager} from 'react-native-volume-manager'; import { GestureDetector, Gesture, GestureHandlerRootView, } from 'react-native-gesture-handler'; type GesturesProps = { forward: (time?: number) => void; rewind: (time?: number) => void; togglePlayPause: () => void; seekerWidth: number; doubleTapTime: number; toggleControls: () => void; tapActionTimeout: React.MutableRefObject; tapAnywhereToPause: boolean; rewindTime: number; showControls: boolean; disableGesture: boolean; setPlayback: (rate: number) => void; // Callback for VLC-style 200% boost. // Receives the full effective volume in [0, 2]: // 0-1 → system volume is doing the work (gain stays at 1) // 1-2 → system is maxed; parent should raise player gain to this value onVolumeChange?: (effectiveVolume: number) => void; }; const SWIPE_RANGE = 370; const Ripple = React.memo( ({ visible, isLeft, totalTime, showControls, }: { visible: boolean; isLeft: boolean; totalTime: number; showControls: boolean; }) => { const screenDimensions = useMemo(() => Dimensions.get('window'), []); const {width: SCREEN_WIDTH, height: SCREEN_HEIGHT} = screenDimensions; const scale = useSharedValue(0); const opacity = useSharedValue(0); React.useEffect(() => { if (visible) { scale.value = withSequence( withTiming(1.5, {duration: 400}), withDelay( 400, withTiming(0, { duration: 400, }), ), ); opacity.value = withSequence( withTiming(0.4, {duration: 400}), withDelay( 400, withTiming(0, { duration: 400, }), ), ); } }, [visible, scale, opacity]); const rippleStyle = useAnimatedStyle( () => ({ opacity: opacity.value, //@ts-ignore transform: [{scale: scale.value}], }), [], ); const containerStyle = useMemo( () => ({ position: 'absolute' as const, top: showControls ? -70 : -45, left: isLeft ? ('-10%' as const) : undefined, right: isLeft ? undefined : ('-10%' as const), width: SCREEN_WIDTH / 2.5, height: SCREEN_HEIGHT, zIndex: 999, }), [showControls, isLeft, SCREEN_WIDTH, SCREEN_HEIGHT], ); const innerStyle = useMemo( () => ({ position: 'absolute' as const, width: '100%' as const, height: '100%' as const, backgroundColor: 'rgba(0,0,0,0.9)', justifyContent: 'center' as const, alignItems: 'center' as const, borderRadius: SCREEN_HEIGHT / 2, }), [SCREEN_HEIGHT], ); const textStyle = useMemo( () => ({ color: 'white', marginTop: 8, fontSize: 12, }), [], ); return visible ? ( {!isNaN(totalTime) && totalTime > 0 && ( {Math.floor(totalTime)}s )} ) : null; }, ); const ControlOverlay = React.memo( ({ value, isVisible, isVolume, showControls, }: { value: number; isVisible: boolean; isVolume: boolean; showControls: boolean; }) => { const containerStyle = useMemo( () => ({ position: 'absolute' as const, top: '50%' as const, left: !isVolume ? undefined : ('15%' as const), right: !isVolume ? ('15%' as const) : undefined, transform: [ {translateX: 0 as const}, {translateY: showControls ? -20 : 0}, ] as const, backgroundColor: 'rgba(0, 0, 0, 0.6)', borderRadius: 10, minWidth: 50, padding: 10, alignItems: 'center' as const, zIndex: 1000, }), [isVolume, showControls], ); const textStyle = useMemo( () => ({ color: 'white', marginTop: 5, }), [], ); const iconName = useMemo(() => { if (isVolume) { return value === 0 ? 'volume-mute' : value < 0.3 ? 'volume-down' : 'volume-up'; } return 'brightness-6'; }, [isVolume, value]); if (!isVisible) return null; return ( {Math.round(value * 100)} ); }, ); const Gestures = ({ forward, rewind, togglePlayPause, toggleControls, doubleTapTime, tapActionTimeout, tapAnywhereToPause, rewindTime = 10, showControls, disableGesture, setPlayback, onVolumeChange, }: GesturesProps) => { const [rippleVisible, setRippleVisible] = useState(false); const [isLeftRipple, setIsLeftRipple] = useState(false); const [totalSkipTime, setTotalSkipTime] = useState(0); const [displayVolume, setDisplayVolume] = useState(0); const [displayBrightness, setDisplayBrightness] = useState(0); const [isVolumeVisible, setIsVolumeVisible] = useState(false); const [isBrightnessVisible, setIsBrightnessVisible] = useState(false); const [toastMessage, setToastMessage] = useState(null); // Memoize screen dimensions const screenDimensions = useMemo(() => Dimensions.get('window'), []); const {width: SCREEN_WIDTH} = screenDimensions; // Refs const initialTapPosition = useRef({x: 0, y: 0}); const isDoubleTapRef = useRef(false); const currentSideRef = useRef<'left' | 'right' | null>(null); const tapCountRef = useRef(0); const skipTimeoutRef = useRef(null); const lastTapTimeRef = useRef(0); const originalSettings = useRef({ volume: 0, brightness: 0, }); // Shared values const volumeValue = useSharedValue(0); const brightnessValue = useSharedValue(0); const startVolume = useSharedValue(0); const startBrightness = useSharedValue(0); const toastOpacity = useSharedValue(0); // Toast styles (inline, no Tailwind) const toastAnimatedStyle = useAnimatedStyle( () => ({ opacity: toastOpacity.value, transform: [ { translateY: (1 - toastOpacity.value) * -4, // subtle lift-in }, ] as any, }), [], ); const toastContainerStyle = useMemo( () => ({ position: 'absolute' as const, width: '100%' as const, top: 48, // ~ top-12 justifyContent: 'center' as const, alignItems: 'center' as const, paddingHorizontal: 8, // px-2 zIndex: 1200, }), [], ); const toastTextStyle = useMemo( () => ({ color: 'white', // text-white backgroundColor: 'rgba(0,0,0,0.5)', // bg-black/50 padding: 8, // p-2 borderRadius: 9999, // rounded-full fontSize: 16, // text-base }), [], ); const show2xToast = useCallback(() => { setToastMessage('2× speed'); toastOpacity.value = withTiming(1, {duration: 150}); }, [toastOpacity]); const hideToast = useCallback(() => { toastOpacity.value = withTiming(0, {duration: 150}, (finished) => { if (finished) { runOnJS(setToastMessage)(null); } }); }, [toastOpacity]); const resetState = useCallback(() => { isDoubleTapRef.current = false; currentSideRef.current = null; tapCountRef.current = 0; lastTapTimeRef.current = 0; setTotalSkipTime(0); setRippleVisible(false); if (skipTimeoutRef.current) { clearTimeout(skipTimeoutRef.current); skipTimeoutRef.current = null; } }, []); const handleSkip = useCallback(async () => { try { const count = Number(tapCountRef.current) - 1; const baseTime = Number(rewindTime); const skipTime = baseTime * count; if (!isNaN(skipTime) && skipTime > 0) { if (currentSideRef.current === 'left') { rewind(skipTime); } else if (currentSideRef.current === 'right') { forward(skipTime); } } } catch (error) { console.error('Error while skipping:', error); } finally { resetState(); } }, [rewindTime, rewind, forward, resetState]); const handleTap = useCallback( (e: GestureResponderEvent, side: 'left' | 'right') => { const now = Date.now(); const touchX = e.nativeEvent.locationX; const touchY = e.nativeEvent.locationY; if (now - lastTapTimeRef.current > 500) { resetState(); } if (!isDoubleTapRef.current) { isDoubleTapRef.current = true; initialTapPosition.current = {x: touchX, y: touchY}; currentSideRef.current = side; tapCountRef.current = 1; lastTapTimeRef.current = now; tapActionTimeout.current = setTimeout(() => { if (tapAnywhereToPause) { togglePlayPause(); } else { toggleControls(); } resetState(); }, doubleTapTime); } else { if (tapActionTimeout.current) { clearTimeout(tapActionTimeout.current); tapActionTimeout.current = null; } if (currentSideRef.current === side) { tapCountRef.current += 1; lastTapTimeRef.current = now; const count = Number(tapCountRef.current) - 1; const baseTime = Number(rewindTime); const newSkipTime = baseTime * count; setTotalSkipTime(newSkipTime); setRippleVisible(true); setIsLeftRipple(side === 'left'); if (skipTimeoutRef.current) { clearTimeout(skipTimeoutRef.current); } skipTimeoutRef.current = setTimeout(handleSkip, 500); } else { resetState(); isDoubleTapRef.current = true; initialTapPosition.current = {x: touchX, y: touchY}; currentSideRef.current = side; tapCountRef.current = 1; lastTapTimeRef.current = now; tapActionTimeout.current = setTimeout(() => { resetState(); }, doubleTapTime); } } }, [ resetState, tapAnywhereToPause, togglePlayPause, toggleControls, doubleTapTime, rewindTime, handleSkip, ], ); // ── FIXED: Safely update Volume ── const updateSystemVolume = useCallback( (newVolume: number) => { // 1. Update the screen HUD setDisplayVolume(newVolume); // 2. Send the exact 0.0 - 2.0 value up to VideoPlayer.tsx onVolumeChange?.(newVolume); // 3. Update the actual device system volume. const hardwareVolume = Math.min(1, Math.max(0, newVolume)); // Prevent native crash when Activity is destroyed VolumeManager.setVolume(hardwareVolume).catch((error: any) => { if ( error.message?.includes('current activity is no longer available') ) { console.warn('[Gestures/Volume] Activity gone, volume set skipped.'); } else { console.error('[Gestures/Volume] Set volume error:', error); } }); }, [onVolumeChange], ); // ── FIXED: Safely update Brightness ── const updateSystemBrightness = useCallback((newBrightness: number) => { const clampedBrightness = Math.max(0, Math.min(1, newBrightness)); setDisplayBrightness(clampedBrightness); // Prevent native crash when Activity is destroyed Brightness.setBrightnessAsync(clampedBrightness).catch((error: any) => { if (error.message?.includes('current activity is no longer available')) { console.warn( '[Gestures/Brightness] Activity gone, brightness set skipped.', ); } else { console.error('[Gestures/Brightness] Set brightness error:', error); } }); }, []); const panGesture = useMemo( () => Gesture.Pan() // Vertical-only: the gesture only "activates" (and starts moving // the volume/brightness value) once the finger has moved at least // 8px up or down. If it instead moves more than 16px sideways // before that happens, the gesture fails outright — so a diagonal // or horizontal swipe never bleeds into a volume/brightness change. .activeOffsetY([-8, 8]) .failOffsetX([-16, 16]) .onStart((event) => { 'worklet'; const isLeftSide = event.x < SCREEN_WIDTH / 2; if (isLeftSide) { startBrightness.value = brightnessValue.value; runOnJS(setIsBrightnessVisible)(true); } else { startVolume.value = volumeValue.value; runOnJS(setIsVolumeVisible)(true); } }) .onUpdate((event) => { 'worklet'; const isLeftSide = event.x < SCREEN_WIDTH / 2; const change = -event.translationY / SWIPE_RANGE; if (isLeftSide) { // Brightness control const newBrightness = Math.max( 0, Math.min(1, startBrightness.value + change), ); brightnessValue.value = newBrightness; runOnJS(updateSystemBrightness)(newBrightness); } else { // Volume control const newVolume = Math.max( 0, Math.min(2, startVolume.value + change), ); volumeValue.value = newVolume; runOnJS(updateSystemVolume)(newVolume); } }) .onFinalize(() => { 'worklet'; runOnJS(setIsVolumeVisible)(false); runOnJS(setIsBrightnessVisible)(false); }), [SCREEN_WIDTH, updateSystemBrightness, updateSystemVolume], ); // ── FIXED: Separate try/catch for Volume and Brightness to prevent Promise.all crashing ── useEffect(() => { let mounted = true; const initializeSettings = async () => { let vol = {volume: 0.5}; let bright = 0.5; // Get volume safely try { vol = await VolumeManager.getVolume(); } catch (error: any) { if ( error.message?.includes('current activity is no longer available') ) { console.warn('[Gestures] Activity gone during volume init.'); } else { console.error('[Gestures] Error init volume:', error); } } // Get brightness safely try { bright = await Brightness.getBrightnessAsync(); } catch (error: any) { if ( error.message?.includes('current activity is no longer available') ) { console.warn('[Gestures] Activity gone during brightness init.'); } else { console.error('[Gestures] Error init brightness:', error); } } if (mounted) { originalSettings.current = { volume: vol.volume, brightness: bright, }; volumeValue.value = vol.volume; brightnessValue.value = bright; setDisplayVolume(vol.volume); setDisplayBrightness(bright); } }; initializeSettings(); return () => { mounted = false; }; }, []); // Cleanup useEffect useEffect(() => { return () => { if (skipTimeoutRef.current) { clearTimeout(skipTimeoutRef.current); } if (tapActionTimeout.current) { clearTimeout(tapActionTimeout.current); } }; }, []); // Memoize container styles const containerStyle = useMemo( () => ({ width: '100%' as const, height: '70%' as const, }), [], ); const gestureContainerStyle = useMemo( () => ({ position: 'relative' as const, width: '100%' as const, height: '100%' as const, flexDirection: 'row' as const, }), [], ); const leftPressableStyle = useMemo( () => ({ flex: 1, top: 40, height: '100%' as const, position: 'relative' as const, }), [], ); const rightPressableStyle = useMemo( () => ({ top: 40, flex: 1, height: '100%' as const, position: 'relative' as const, }), [], ); // Memoized handler functions const handleLeftTap = useCallback( (e: GestureResponderEvent) => { handleTap(e, 'left'); }, [handleTap], ); const handleRightTap = useCallback( (e: GestureResponderEvent) => { handleTap(e, 'right'); }, [handleTap], ); if (disableGesture) { return null; } return ( {/* Left side for brightness */} {/* Right side for volume */} { setPlayback(2); show2xToast(); }} onPressOut={() => { setPlayback(1); hideToast(); }}> {/* 2x speed toast */} {toastMessage ? ( {toastMessage} ) : null} {/* Overlays */} ); }; export default Gestures;