| import React, { useEffect, useState, useRef } from 'react'; |
| import { |
| View, |
| StyleSheet, |
| TouchableOpacity, |
| Text, |
| TouchableWithoutFeedback, |
| PanResponder, |
| } from 'react-native'; |
| import { useVideoPlayer, VideoView } from 'expo-video'; |
| import { MaterialIcons } from '@expo/vector-icons'; |
| import { useSafeAreaInsets } from 'react-native-safe-area-context'; |
|
|
| interface CustomVideoPlayerProps { |
| uri: string; |
| shouldPlay: boolean; |
| onScrubStart?: () => void; |
| onScrubEnd?: () => void; |
| } |
|
|
| export default function CustomVideoPlayer({ |
| uri, shouldPlay, onScrubStart, onScrubEnd, |
| }: CustomVideoPlayerProps) { |
| const insets = useSafeAreaInsets(); |
| const [isPlaying, setIsPlaying] = useState(shouldPlay); |
| const [currentTime, setCurrentTime] = useState(0); |
| const [duration, setDuration] = useState(0); |
| const [showControls, setShowControls] = useState(true); |
| const [scrubberWidth, setScrubberWidth] = useState(0); |
|
|
| const scrubberPageXRef = useRef(0); |
| const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
|
|
| const player = useVideoPlayer(uri, (p) => { |
| p.loop = true; |
| if (shouldPlay) p.play(); |
| }); |
|
|
| useEffect(() => { |
| if (shouldPlay) { |
| player.play(); |
| setIsPlaying(true); |
| } else { |
| player.pause(); |
| setIsPlaying(false); |
| } |
| }, [shouldPlay, player]); |
|
|
| useEffect(() => { |
| const interval = setInterval(() => { |
| if (player) { |
| setCurrentTime(player.currentTime); |
| if (player.duration) setDuration(player.duration); |
| } |
| }, 250); |
| return () => clearInterval(interval); |
| }, [player]); |
|
|
| const resetControlsTimeout = () => { |
| setShowControls(true); |
| if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current); |
| controlsTimeoutRef.current = setTimeout(() => { |
| setShowControls(false); |
| }, 3000); |
| }; |
|
|
| useEffect(() => { |
| if (isPlaying) { |
| resetControlsTimeout(); |
| } else { |
| if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current); |
| setShowControls(true); |
| } |
| return () => { |
| if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current); |
| }; |
| }, [isPlaying]); |
|
|
| const togglePlayPause = () => { |
| if (isPlaying) { |
| player.pause(); |
| setIsPlaying(false); |
| } else { |
| player.play(); |
| setIsPlaying(true); |
| } |
| }; |
|
|
| const handleScreenTap = () => { |
| if (showControls) { |
| setShowControls(false); |
| if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current); |
| } else { |
| resetControlsTimeout(); |
| } |
| }; |
|
|
| const seekFromAbsoluteX = (absX: number) => { |
| if (duration <= 0 || scrubberWidth <= 0) return; |
| const relX = absX - scrubberPageXRef.current; |
| const pct = Math.max(0, Math.min(1, relX / scrubberWidth)); |
| const seekTime = pct * duration; |
| player.currentTime = seekTime; |
| setCurrentTime(seekTime); |
| }; |
|
|
| const handleScrubberLayout = () => { |
| scrubberViewRef.current?.measureInWindow((x, _y, w, _h) => { |
| scrubberPageXRef.current = x; |
| setScrubberWidth(w); |
| }); |
| }; |
|
|
| const scrubberViewRef = useRef<View>(null); |
|
|
| |
| |
| |
| |
| const panResponder = useRef( |
| PanResponder.create({ |
| onStartShouldSetPanResponder: () => true, |
| onMoveShouldSetPanResponder: () => true, |
| onPanResponderGrant: (e) => { |
| onScrubStart?.(); |
| seekFromAbsoluteX(e.nativeEvent.pageX); |
| }, |
| onPanResponderMove: (e) => { |
| seekFromAbsoluteX(e.nativeEvent.pageX); |
| }, |
| onPanResponderRelease: () => { |
| onScrubEnd?.(); |
| resetControlsTimeout(); |
| }, |
| onPanResponderTerminate: () => { |
| onScrubEnd?.(); |
| resetControlsTimeout(); |
| }, |
| }) |
| ).current; |
|
|
| const formatTime = (secs: number) => { |
| const m = Math.floor(secs / 60); |
| const s = Math.floor(secs % 60); |
| return `${m}:${s < 10 ? '0' : ''}${s}`; |
| }; |
|
|
| const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; |
|
|
| return ( |
| <TouchableWithoutFeedback onPress={handleScreenTap}> |
| <View style={styles.container}> |
| <VideoView |
| style={styles.video} |
| player={player} |
| allowsFullscreen={false} |
| allowsPictureInPicture={false} |
| nativeControls={false} |
| /> |
| |
| {showControls && ( |
| <View style={styles.overlay} pointerEvents="box-none"> |
| <TouchableOpacity |
| style={styles.playPauseBtn} |
| onPress={togglePlayPause} |
| activeOpacity={0.7} |
| > |
| <MaterialIcons |
| name={isPlaying ? 'pause' : 'play-arrow'} |
| size={48} |
| color="#fff" |
| /> |
| </TouchableOpacity> |
| |
| <View style={[styles.bottomBar, { paddingBottom: insets.bottom + 120 }]}> |
| <Text style={styles.timeText}>{formatTime(currentTime)}</Text> |
| |
| {/* Scrubber — Using PanResponder ensures FlatList stops scrolling */} |
| <View |
| {...panResponder.panHandlers} |
| ref={scrubberViewRef} |
| style={styles.scrubberContainer} |
| onLayout={handleScrubberLayout} |
| > |
| <View style={styles.scrubberTrack}> |
| <View style={[styles.scrubberFill, { width: `${progressPercent}%` }]} /> |
| <View style={[styles.scrubberThumb, { left: `${progressPercent}%` }]} /> |
| </View> |
| </View> |
| |
| <Text style={styles.timeText}>{formatTime(duration)}</Text> |
| </View> |
| </View> |
| )} |
| </View> |
| </TouchableWithoutFeedback> |
| ); |
| } |
|
|
| const styles = StyleSheet.create({ |
| container: { |
| flex: 1, |
| width: '100%', |
| height: '100%', |
| backgroundColor: '#000', |
| justifyContent: 'center', |
| alignItems: 'center', |
| }, |
| video: { |
| width: '100%', |
| height: '100%', |
| }, |
| overlay: { |
| ...StyleSheet.absoluteFillObject, |
| justifyContent: 'center', |
| alignItems: 'center', |
| backgroundColor: 'rgba(0,0,0,0.3)', |
| }, |
| playPauseBtn: { |
| width: 80, |
| height: 80, |
| borderRadius: 40, |
| backgroundColor: 'rgba(0,0,0,0.6)', |
| justifyContent: 'center', |
| alignItems: 'center', |
| }, |
| bottomBar: { |
| position: 'absolute', |
| bottom: 0, |
| left: 0, |
| right: 0, |
| flexDirection: 'row', |
| alignItems: 'center', |
| paddingHorizontal: 16, |
| }, |
| timeText: { |
| color: '#fff', |
| fontSize: 13, |
| fontWeight: '600', |
| width: 44, |
| textAlign: 'center', |
| }, |
| scrubberContainer: { |
| flex: 1, |
| height: 44, |
| justifyContent: 'center', |
| marginHorizontal: 8, |
| }, |
| scrubberTrack: { |
| height: 4, |
| backgroundColor: 'rgba(255,255,255,0.3)', |
| borderRadius: 2, |
| justifyContent: 'center', |
| }, |
| scrubberFill: { |
| height: 4, |
| backgroundColor: '#1a73e8', |
| borderRadius: 2, |
| position: 'absolute', |
| left: 0, |
| }, |
| scrubberThumb: { |
| width: 16, |
| height: 16, |
| borderRadius: 8, |
| backgroundColor: '#1a73e8', |
| position: 'absolute', |
| marginLeft: -8, |
| }, |
| }); |
|
|