import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import { useEvent } from 'expo'; import { useVideoPlayer, VideoSource, VideoView } from 'expo-video'; import { Pause, Play, Volume2, VolumeX } from 'lucide-react-native'; import React, { forwardRef, useCallback, useEffect, useRef, useState, } from 'react'; import { StyleSheet, Text, TouchableOpacity, View, ViewStyle, } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; interface VideoProps { source: VideoSource; style?: ViewStyle; seekBy?: number; // seconds to seek by on double tap autoPlay?: boolean; loop?: boolean; muted?: boolean; nativeControls?: boolean; showControls?: boolean; allowsFullscreen?: boolean; allowsPictureInPicture?: boolean; contentFit?: 'contain' | 'cover' | 'fill'; onLoad?: () => void; onError?: (error: any) => void; onPlaybackStatusUpdate?: (status: any) => void; onFullscreenUpdate?: (isFullscreen: boolean) => void; subtitles?: Array<{ start: number; end: number; text: string; }>; } interface VideoRef { play: () => void; pause: () => void; seekTo: (seconds: number) => void; setVolume: (volume: number) => void; getCurrentTime: () => number; getDuration: () => number; isPlaying: () => boolean; isMuted: () => boolean; } // Helper function to format time const formatTime = (seconds: number): string => { if (isNaN(seconds) || seconds < 0) return '0:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; // --- Custom Reanimated Progress Bar --- const PROGRESS_HEIGHT = 8; const THUMB_SIZE = 16; interface ReanimatedProgressProps { duration: number; currentTime: number; onSeek: (progress: number) => void; onSeekStart?: () => void; onSeekEnd?: () => void; } const ReanimatedProgress = ({ duration, currentTime, onSeek, onSeekStart, onSeekEnd, }: ReanimatedProgressProps) => { const [barWidth, setBarWidth] = useState(0); const isScrubbing = useSharedValue(false); const translateX = useSharedValue(0); const scale = useSharedValue(1); useDerivedValue(() => { if (!isScrubbing.value && duration > 0 && barWidth > 0) { const progress = currentTime / duration; translateX.value = withTiming(progress * barWidth, { duration: 100 }); } }); const panGesture = Gesture.Pan() .minDistance(1) .onBegin(() => { isScrubbing.value = true; scale.value = withTiming(1.2); if (onSeekStart) runOnJS(onSeekStart)(); }) .onChange((event) => { translateX.value = Math.max( 0, Math.min(barWidth, translateX.value + event.changeX) ); }) .onEnd(() => { const finalProgress = translateX.value / barWidth; runOnJS(onSeek)(finalProgress); isScrubbing.value = false; scale.value = withTiming(1); if (onSeekEnd) runOnJS(onSeekEnd)(); }); const tapGesture = Gesture.Tap() .onBegin(() => { if (onSeekStart) runOnJS(onSeekStart)(); }) .onEnd((event) => { const newTranslateX = Math.max(0, Math.min(barWidth, event.x)); translateX.value = newTranslateX; const finalProgress = newTranslateX / barWidth; runOnJS(onSeek)(finalProgress); if (onSeekEnd) runOnJS(onSeekEnd)(); }); const composedGesture = Gesture.Race(panGesture, tapGesture); const animatedProgressStyle = useAnimatedStyle(() => ({ width: translateX.value, })); const animatedThumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }, { scale: scale.value }], })); return ( setBarWidth(e.nativeEvent.layout.width)} > ); }; // --- Main Video Component --- export const Video = forwardRef( ( { source, style, autoPlay = false, loop = false, muted = false, nativeControls = false, allowsFullscreen = true, allowsPictureInPicture = true, contentFit = 'cover', onLoad, onError, seekBy = 2, onPlaybackStatusUpdate, onFullscreenUpdate, subtitles = [], ...props }, ref ) => { const textColor = useColor('text'); const cardColor = useColor('card'); const mutedColor = useColor('mutedForeground'); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [isMuted, setIsMuted] = useState(muted); const [currentSubtitle, setCurrentSubtitle] = useState(''); const [isVideoEnded, setIsVideoEnded] = useState(false); const [showPlayIcon, setShowPlayIcon] = useState(false); const [showCustomControls, setShowCustomControls] = useState(false); const [isSeeking, setIsSeeking] = useState(false); const hideControlsTimeout = useRef(null); const hidePlayIconTimeout = useRef(null); const controlsOpacity = useSharedValue(0); const playIconOpacity = useSharedValue(0); const player = useVideoPlayer(source, (player) => { try { if (autoPlay && player.play) player.play(); player.loop = loop; player.muted = muted; onLoad?.(); } catch (error) { console.error('Video player initialization error:', error); onError?.(error); } }); const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player?.playing || false, }); // --- !! EFFECT UPDATED TO RESPECT isSeeking STATE !! --- useEffect(() => { const interval = setInterval(() => { // Only update time from player if the user is not actively seeking if (player && !isSeeking) { const time = player.currentTime || 0; const dur = player.duration || 0; setCurrentTime(time); if (dur > 0) setDuration(dur); if (dur > 0 && time >= dur - 0.25 && !loop) setIsVideoEnded(true); else setIsVideoEnded(false); const activeSubtitle = subtitles.find( (s) => time >= s.start && time <= s.end ); setCurrentSubtitle(activeSubtitle?.text || ''); onPlaybackStatusUpdate?.({ currentTime: time, duration: dur, isPlaying: player.playing, }); } }, 250); return () => clearInterval(interval); }, [player, subtitles, onPlaybackStatusUpdate, loop, isSeeking]); const controlsAnimatedStyle = useAnimatedStyle(() => ({ opacity: controlsOpacity.value, })); const playIconAnimatedStyle = useAnimatedStyle(() => ({ opacity: playIconOpacity.value, })); const showControls = useCallback(() => { setShowCustomControls(true); controlsOpacity.value = withTiming(1, { duration: 200 }); if (hideControlsTimeout.current) clearTimeout(hideControlsTimeout.current); if (isPlaying) { // Only hide controls if video is playing hideControlsTimeout.current = setTimeout(hideControls, 3000); } }, [controlsOpacity, isPlaying]); const hideControls = useCallback(() => { controlsOpacity.value = withTiming(0, { duration: 200 }, (isFinished) => { if (isFinished) runOnJS(setShowCustomControls)(false); }); }, [controlsOpacity]); const showPlayIconAnimation = useCallback(() => { setShowPlayIcon(true); playIconOpacity.value = withTiming(1, { duration: 200 }); if (hidePlayIconTimeout.current) clearTimeout(hidePlayIconTimeout.current); hidePlayIconTimeout.current = setTimeout(() => { playIconOpacity.value = withTiming( 0, { duration: 200 }, (isFinished) => { if (isFinished) runOnJS(setShowPlayIcon)(false); } ); }, 1000); }, [playIconOpacity]); const handleSingleTap = useCallback(() => { if (!player) return; if (isVideoEnded) { player.currentTime = 0; player.play(); setIsVideoEnded(false); } else { player.playing ? player.pause() : player.play(); } showPlayIconAnimation(); showControls(); }, [player, isVideoEnded, showControls, showPlayIconAnimation]); const handleLeftDoubleTap = useCallback(() => { if (player) { player.seekBy(-seekBy); showControls(); } }, [player, showControls, seekBy]); const handleRightDoubleTap = useCallback(() => { if (player) { player.seekBy(seekBy); showControls(); } }, [player, showControls, seekBy]); const toggleMute = useCallback(() => { const newMuted = !isMuted; setIsMuted(newMuted); player.muted = newMuted; }, [isMuted, player]); const handleProgressChange = useCallback( (progress: number) => { if (!player || !duration || duration <= 0) return; const newTime = progress * duration; // This is the "optimistic update" - we set the local state immediately setCurrentTime(newTime); player.currentTime = newTime; if (isVideoEnded) setIsVideoEnded(false); // Reset the hide controls timer if (hideControlsTimeout.current) clearTimeout(hideControlsTimeout.current); hideControlsTimeout.current = setTimeout(hideControls, 3000); }, [player, duration, isVideoEnded, hideControls] ); const handleSeekStart = useCallback(() => { setIsSeeking(true); if (hideControlsTimeout.current) clearTimeout(hideControlsTimeout.current); }, []); const handleSeekEnd = useCallback(() => { setIsSeeking(false); }, []); useEffect(() => { return () => { if (hideControlsTimeout.current) clearTimeout(hideControlsTimeout.current); if (hidePlayIconTimeout.current) clearTimeout(hidePlayIconTimeout.current); }; }, []); return ( onFullscreenUpdate?.(true)} onFullscreenExit={() => onFullscreenUpdate?.(false)} {...props} /> {showCustomControls && ( {isMuted ? ( ) : ( )} {formatTime(currentTime)} {formatTime(duration)} )} ); } ); Video.displayName = 'Video'; const styles = StyleSheet.create({ container: { width: '100%', height: '100%', borderRadius: BORDER_RADIUS, overflow: 'hidden', }, video: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, width: '100%', height: '100%', }, gestureOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, flexDirection: 'row', }, gestureArea: { flex: 1, backgroundColor: 'transparent' }, gestureAreaCenter: { flex: 2, backgroundColor: 'transparent' }, centerPlayIcon: { position: 'absolute', top: '50%', left: '50%', transform: [{ translateX: -40 }, { translateY: -40 }], zIndex: 100, }, centerPlayIconBackground: { width: 80, height: 80, borderRadius: 40, backgroundColor: 'rgba(0, 0, 0, 0.7)', justifyContent: 'center', alignItems: 'center', }, subtitleContainer: { position: 'absolute', bottom: 80, left: 20, right: 20, alignItems: 'center', }, subtitleText: { fontSize: 16, textAlign: 'center', backgroundColor: 'rgba(0, 0, 0, 0.7)', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 6, }, controlsContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.3)', justifyContent: 'space-between', }, topControls: { flexDirection: 'row', justifyContent: 'flex-end', padding: 16, }, bottomControls: { padding: 16, gap: 6, paddingBottom: 6 }, timeContainer: { flexDirection: 'row', justifyContent: 'space-between' }, timeText: { fontSize: 12 }, controlButton: { width: 44, height: 44, borderRadius: 22, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0, 0, 0, 0.5)', }, }); const progressStyles = StyleSheet.create({ container: { height: THUMB_SIZE * 2, justifyContent: 'center' }, track: { height: PROGRESS_HEIGHT, backgroundColor: 'rgba(255, 255, 255, 0.3)', borderRadius: PROGRESS_HEIGHT / 2, }, progress: { height: PROGRESS_HEIGHT, backgroundColor: '#FFFFFF', borderRadius: PROGRESS_HEIGHT / 2, position: 'absolute', }, thumbContainer: { position: 'absolute', top: (THUMB_SIZE * 2 - THUMB_SIZE) / 2, left: -THUMB_SIZE / 2, }, thumb: { width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: THUMB_SIZE / 2, backgroundColor: '#FFFFFF', }, }); export type { VideoProps, VideoRef, VideoSource };