130 lines
4.2 KiB
TypeScript
130 lines
4.2 KiB
TypeScript
|
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<number>;
|
||
|
currentTime: SharedValue<number>;
|
||
|
loaded: boolean;
|
||
|
onChangeTime: (time: number) => Promise<void>;
|
||
|
}
|
||
|
|
||
|
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<PanGestureHandlerGestureEvent, { offsetX: number }>({
|
||
|
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 (
|
||
|
<TouchableNativeFeedback>
|
||
|
<View style={styles.seekContainer}>
|
||
|
<AnimatedTextInput
|
||
|
defaultValue={DEFAULT_TIME_LABEL}
|
||
|
editable={false}
|
||
|
style={[styles.duration, { color: colors.fontDefault }]}
|
||
|
animatedProps={timeLabelAnimatedProps}
|
||
|
/>
|
||
|
<View style={styles.seek} onLayout={onLayout}>
|
||
|
<View style={[styles.line, { backgroundColor: colors.strokeLight }]}>
|
||
|
<Animated.View style={[styles.line, styleLine, { backgroundColor: colors.buttonBackgroundPrimaryDefault }]} />
|
||
|
</View>
|
||
|
<PanGestureHandler enabled={loaded} onGestureEvent={onGestureEvent} activeOffsetX={[-ACTIVE_OFFSET_X, ACTIVE_OFFSET_X]}>
|
||
|
<Animated.View hitSlop={SEEK_HIT_SLOP} style={[styles.thumbSeek, { backgroundColor: thumbColor }, styleThumb]} />
|
||
|
</PanGestureHandler>
|
||
|
</View>
|
||
|
</View>
|
||
|
</TouchableNativeFeedback>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
export default Seek;
|