import React from 'react'; import { LayoutChangeEvent, View, TextInput, TextInputProps, TouchableNativeFeedback } from 'react-native'; import { PanGestureHandler, PanGestureHandlerGestureEvent } from 'react-native-gesture-handler'; import Animated, { SharedValue, runOnJS, useAnimatedGestureHandler, useAnimatedProps, useAnimatedStyle, useDerivedValue, useSharedValue } from 'react-native-reanimated'; import styles from './styles'; import { useTheme } from '../../theme'; import { SEEK_HIT_SLOP, THUMB_SEEK_SIZE, ACTIVE_OFFSET_X, DEFAULT_TIME_LABEL } from './constants'; const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); interface ISeek { duration: SharedValue; currentTime: SharedValue; loaded: boolean; onChangeTime: (time: number) => Promise; } function clamp(value: number, min: number, max: number) { 'worklet'; return Math.min(Math.max(value, min), max); } // https://docs.swmansion.com/react-native-reanimated/docs/2.x/fundamentals/worklets/ const formatTime = (ms: number) => { 'worklet'; const minutes = Math.floor(ms / 60); const remainingSeconds = Math.floor(ms % 60); const formattedMinutes = String(minutes).padStart(2, '0'); const formattedSeconds = String(remainingSeconds).padStart(2, '0'); return `${formattedMinutes}:${formattedSeconds}`; }; const Seek = ({ currentTime, duration, loaded = false, onChangeTime }: ISeek) => { const { colors } = useTheme(); const maxWidth = useSharedValue(1); const translateX = useSharedValue(0); const timeLabel = useSharedValue(DEFAULT_TIME_LABEL); const scale = useSharedValue(1); const isPanning = useSharedValue(false); const styleLine = useAnimatedStyle(() => ({ width: translateX.value })); const styleThumb = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value - THUMB_SEEK_SIZE / 2 }, { scale: scale.value }] })); const onLayout = (event: LayoutChangeEvent) => { const { width } = event.nativeEvent.layout; maxWidth.value = width; }; const onGestureEvent = useAnimatedGestureHandler({ onStart: (event, ctx) => { isPanning.value = true; ctx.offsetX = translateX.value; }, onActive: ({ translationX }, ctx) => { translateX.value = clamp(ctx.offsetX + translationX, 0, maxWidth.value); scale.value = 1.3; }, onFinish() { scale.value = 1; isPanning.value = false; runOnJS(onChangeTime)(Math.round(currentTime.value * 1000)); } }); useDerivedValue(() => { if (isPanning.value) { // When the user is panning, always the currentTime.value is been set different from the currentTime provided by // the audio in progress currentTime.value = (translateX.value * duration.value) / maxWidth.value || 0; } else { translateX.value = (currentTime.value * maxWidth.value) / duration.value || 0; } timeLabel.value = formatTime(currentTime.value); }, [translateX, maxWidth, duration, isPanning, currentTime]); const timeLabelAnimatedProps = useAnimatedProps(() => { if (currentTime.value !== 0) { return { text: timeLabel.value } as TextInputProps; } return { text: formatTime(duration.value) } as TextInputProps; }, [timeLabel, duration, currentTime]); const thumbColor = loaded ? colors.buttonBackgroundPrimaryDefault : colors.tintDisabled; // TouchableNativeFeedback is avoiding do a long press message when seeking the audio return ( ); }; export default Seek;