diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index 028eb25..c263702 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -8,13 +8,12 @@ import { mapMillisecondsToTime } from "./utils"; export const BottomControls = () => { const status = usePlayerStore((state) => state.status); - status?.isLoaded; if (status?.isLoaded) { return ( - + {mapMillisecondsToTime(status.positionMillis ?? 0)} diff --git a/apps/expo/src/components/player/ProgressBar.tsx b/apps/expo/src/components/player/ProgressBar.tsx index a2b6a3f..1eb24b2 100644 --- a/apps/expo/src/components/player/ProgressBar.tsx +++ b/apps/expo/src/components/player/ProgressBar.tsx @@ -1,79 +1,26 @@ -import { useCallback, useRef } from "react"; -import { Dimensions, PanResponder, TouchableOpacity, View } from "react-native"; +import { useCallback } from "react"; +import { View } from "react-native"; import { usePlayerStore } from "~/stores/player/store"; +import VideoSlider from "./VideoSlider"; export const ProgressBar = () => { const status = usePlayerStore((state) => state.status); const videoRef = usePlayerStore((state) => state.videoRef); - const screenWidth = Dimensions.get("window").width; - const progressBarWidth = screenWidth - 40; // Adjust the padding as needed - const updateProgress = useCallback( (newProgress: number) => { videoRef?.setStatusAsync({ positionMillis: newProgress }).catch(() => { - console.log("Error updating progress"); + console.error("Error updating progress"); }); }, [videoRef], ); - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onMoveShouldSetPanResponder: () => true, - onPanResponderMove: (e, gestureState) => { - console.log(gestureState.moveX, gestureState.x0, gestureState.dx); - }, - onPanResponderRelease: (e, gestureState) => { - console.log("onPanResponderRelease"); - const { moveX, x0 } = gestureState; - const newProgress = (moveX - x0) / progressBarWidth; - updateProgress(newProgress); - }, - }), - ).current; - if (status?.isLoaded) { - const progressRatio = - status.durationMillis && status.durationMillis !== 0 - ? status.positionMillis / status.durationMillis - : 0; return ( - - {/* Progress Dot */} - - - - - {/* Full bar */} - - {/* TODO: Preloaded */} - - - {/* Progress */} - - + + ); } diff --git a/apps/expo/src/components/player/VideoSlider.tsx b/apps/expo/src/components/player/VideoSlider.tsx new file mode 100644 index 0000000..7f6be7c --- /dev/null +++ b/apps/expo/src/components/player/VideoSlider.tsx @@ -0,0 +1,167 @@ +import type { + HandlerStateChangeEvent, + PanGestureHandlerGestureEvent, + TapGestureHandlerEventPayload, +} from "react-native-gesture-handler"; +import React, { useEffect, useRef } from "react"; +import { Dimensions, StyleSheet, View } from "react-native"; +import { + PanGestureHandler, + State, + TapGestureHandler, +} from "react-native-gesture-handler"; +import Animated, { + runOnJS, + useAnimatedGestureHandler, + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; + +import colors from "@movie-web/tailwind-config/colors"; + +import { usePlayerStore } from "~/stores/player/store"; + +const clamp = (value: number, lowerBound: number, upperBound: number) => { + "worklet"; + return Math.min(Math.max(lowerBound, value), upperBound); +}; + +interface VideoSliderProps { + onSlidingComplete?: (value: number) => void; +} + +const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { + const status = usePlayerStore((state) => state.status); + + const width = Dimensions.get("screen").width - 140; + const knobSize_ = 20; + const trackSize_ = 8; + const minimumValue = 0; + const maximumValue = status?.isLoaded ? status.durationMillis! : 0; + const value = status?.isLoaded ? status.positionMillis : 0; + + const valueToX = (v: number) => { + if (maximumValue === minimumValue) return 0; + return (width * (v - minimumValue)) / (maximumValue - minimumValue); + }; + const xToValue = (x: number) => { + "worklet"; + if (maximumValue === minimumValue) return minimumValue; + return (x / width) * (maximumValue - minimumValue) + minimumValue; + }; + const valueX = valueToX(value); + const translateX = useSharedValue(valueToX(value)); + + const tapRef = useRef(null); + const panRef = useRef(null); + + useEffect(() => { + translateX.value = clamp(valueX, 0, width - knobSize_); + }, [valueX]); + + const _onSlidingComplete = (xValue: number) => { + "worklet"; + if (onSlidingComplete) runOnJS(onSlidingComplete)(xToValue(xValue)); + }; + + const _onActive = (value: number) => { + "worklet"; + translateX.value = clamp(value, 0, width - knobSize_); + }; + + const onGestureEvent = useAnimatedGestureHandler< + PanGestureHandlerGestureEvent, + { offsetX: number } + >({ + onStart: (_, ctx) => (ctx.offsetX = translateX.value), + onActive: (event, ctx) => _onActive(event.translationX + ctx.offsetX), + }); + + const onTapEvent = ( + event: HandlerStateChangeEvent, + ) => { + if (event.nativeEvent.state === State.ACTIVE) { + _onActive(event.nativeEvent.x); + _onSlidingComplete(event.nativeEvent.x); + } + }; + + const scrollTranslationStyle = useAnimatedStyle(() => { + return { transform: [{ translateX: translateX.value }] }; + }); + + const progressStyle = useAnimatedStyle(() => { + return { + width: translateX.value + knobSize_, + }; + }); + + return ( + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + knob: { + justifyContent: "center", + alignItems: "center", + }, +}); + +export default VideoSlider;