149 lines
4.0 KiB
JavaScript
149 lines
4.0 KiB
JavaScript
import { useEffect, useState } from "react";
|
|
import { StyleSheet, Text, View } from "react-native";
|
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
import Animated, {
|
|
runOnJS,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
} from "react-native-reanimated";
|
|
import { Palette, Style } from "../styles";
|
|
import { FONT_FAMILY } from "../styles/Fonts";
|
|
import { LinearGradient } from "./LinearGradient/LinearGradient";
|
|
|
|
const INITIAL_BOX_SIZE = 6;
|
|
|
|
export default ({
|
|
value,
|
|
maxValue,
|
|
progress,
|
|
onSeek,
|
|
onSeekStart,
|
|
onSeekEnd,
|
|
seekEnabled = false,
|
|
}) => {
|
|
const offset = useSharedValue(0);
|
|
const boxWidth = useSharedValue(INITIAL_BOX_SIZE);
|
|
const [layout, setLayout] = useState(null);
|
|
|
|
const SLIDER_WIDTH = layout?.width;
|
|
const MAX_VALUE = SLIDER_WIDTH - INITIAL_BOX_SIZE;
|
|
|
|
const pan = Gesture.Pan()
|
|
.enabled(seekEnabled)
|
|
.onBegin(() => {
|
|
if (seekEnabled && typeof onSeekStart === "function") {
|
|
// Notify JS thread that user started seeking (e.g., pause audio)
|
|
runOnJS(onSeekStart)();
|
|
}
|
|
})
|
|
.onChange((event) => {
|
|
offset.value =
|
|
Math.abs(offset.value) <= MAX_VALUE
|
|
? offset.value + event.changeX <= 0
|
|
? 0
|
|
: offset.value + event.changeX >= MAX_VALUE
|
|
? MAX_VALUE
|
|
: offset.value + event.changeX
|
|
: offset.value;
|
|
|
|
const newWidth = INITIAL_BOX_SIZE + offset.value;
|
|
boxWidth.value = newWidth;
|
|
})
|
|
.onEnd(() => {
|
|
if (!seekEnabled || !onSeek || !MAX_VALUE) return;
|
|
const ratio =
|
|
MAX_VALUE > 0 ? Math.min(1, Math.max(0, offset.value / MAX_VALUE)) : 0;
|
|
// Reanimated -> JS thread bridge
|
|
runOnJS(onSeek)(ratio);
|
|
})
|
|
.onFinalize(() => {
|
|
if (seekEnabled && typeof onSeekEnd === "function") {
|
|
runOnJS(onSeekEnd)();
|
|
}
|
|
});
|
|
|
|
// Reflect external progress into the slider UI
|
|
useEffect(() => {
|
|
if (typeof progress === "number" && layout?.width) {
|
|
const max = layout.width - INITIAL_BOX_SIZE;
|
|
const clamped = Math.max(0, Math.min(1, progress));
|
|
const newOffset = clamped * max;
|
|
offset.value = newOffset;
|
|
boxWidth.value = INITIAL_BOX_SIZE + newOffset;
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [progress, layout?.width]);
|
|
|
|
const boxStyle = useAnimatedStyle(() => {
|
|
return {
|
|
width: INITIAL_BOX_SIZE + offset.value,
|
|
};
|
|
});
|
|
|
|
const sliderStyle = useAnimatedStyle(() => {
|
|
return {
|
|
transform: [{ translateX: offset.value }],
|
|
};
|
|
});
|
|
|
|
return (
|
|
<View onLayout={(e) => setLayout(e.nativeEvent.layout)}>
|
|
<View style={{ ...styles.sliderTrack, width: SLIDER_WIDTH }}>
|
|
<Animated.View style={[styles.box, boxStyle]}>
|
|
<LinearGradient
|
|
colors={["#F94697", "#7023F7"]}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 0 }}
|
|
style={{ flex: 1, borderRadius: 20 }}
|
|
/>
|
|
</Animated.View>
|
|
<GestureDetector gesture={pan}>
|
|
<Animated.View style={[styles.sliderHandle, sliderStyle]} />
|
|
</GestureDetector>
|
|
</View>
|
|
<View style={{ ...Style.containerSpaceBetween, marginTop: 10 }}>
|
|
<Text style={styles.time}>{value}</Text>
|
|
<Text style={styles.time}>{maxValue}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
box: {
|
|
height: INITIAL_BOX_SIZE,
|
|
borderRadius: 20,
|
|
position: "absolute",
|
|
zIndex: 1,
|
|
},
|
|
sliderHandle: {
|
|
width: 20,
|
|
height: 20,
|
|
backgroundColor: "#f8f9ff",
|
|
borderRadius: 25,
|
|
position: "absolute",
|
|
zIndex: 2,
|
|
borderWidth: 4,
|
|
borderColor: "#9B4DFF",
|
|
shadowColor: "#8951FC",
|
|
shadowOffset: {
|
|
width: 0,
|
|
height: 3,
|
|
},
|
|
shadowOpacity: 0.17,
|
|
shadowRadius: 3.05,
|
|
elevation: 4,
|
|
},
|
|
sliderTrack: {
|
|
height: 6,
|
|
backgroundColor: "#0F0C19",
|
|
borderRadius: 25,
|
|
justifyContent: "center",
|
|
},
|
|
time: {
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
},
|
|
});
|