KeyStone / keystone-app /components /CustomVideoPlayer.tsx
dpv007's picture
fix: resolve 404 on thumbnail serve, scrubber pan responder, zooming issues, and swiper alignment
e179c31
Raw
History Blame Contribute Delete
7.2 kB
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);
/* ── Scrubber gesture ──
Uses standard React Native PanResponder to forcefully capture
touches and prevent the parent FlatList from scrolling horizontally.
*/
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,
},
});