102 lines
2.5 KiB
TypeScript
102 lines
2.5 KiB
TypeScript
import { useEffect } from "react";
|
||
import { View } from "react-native";
|
||
import Animated, {
|
||
Easing,
|
||
cancelAnimation,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withTiming,
|
||
} from "react-native-reanimated";
|
||
|
||
interface PlaybackPageIndicatorProps {
|
||
total: number;
|
||
current: number;
|
||
/** 0–1 progress for the active segment. Source ticks at ~100ms. */
|
||
progress: number;
|
||
paused: boolean;
|
||
}
|
||
|
||
const SEGMENT_GAP = 3;
|
||
const SEGMENT_HEIGHT = 2.5;
|
||
const SMOOTHING_MS = 300;
|
||
|
||
/**
|
||
* Snapchat-style segmented progress bar. Past segments full, future empty,
|
||
* active segment animated. The 300ms linear smoothing absorbs the 100ms
|
||
* tick from the particle view source so motion looks continuous at 60fps.
|
||
*/
|
||
export function PlaybackPageIndicator({
|
||
total,
|
||
current,
|
||
progress,
|
||
paused,
|
||
}: PlaybackPageIndicatorProps) {
|
||
if (total === 0) return null;
|
||
|
||
return (
|
||
<View className="flex-row items-stretch" style={{ gap: SEGMENT_GAP }}>
|
||
{Array.from({ length: total }).map((_, i) => (
|
||
<Segment
|
||
key={i}
|
||
isActive={i === current}
|
||
isPast={i < current}
|
||
progress={progress}
|
||
paused={paused}
|
||
/>
|
||
))}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
interface SegmentProps {
|
||
isActive: boolean;
|
||
isPast: boolean;
|
||
progress: number;
|
||
paused: boolean;
|
||
}
|
||
|
||
function Segment({ isActive, isPast, progress, paused }: SegmentProps) {
|
||
// Each segment owns its own width animation. Past = 1, future = 0,
|
||
// active = animated toward `progress`. Reanimated keeps the tween on the
|
||
// UI thread so JS thread stalls (e.g. the 100ms text tick re-render)
|
||
// can't drop frames here.
|
||
const fill = useSharedValue(isPast ? 1 : 0);
|
||
|
||
useEffect(() => {
|
||
if (isPast) {
|
||
cancelAnimation(fill);
|
||
fill.value = withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) });
|
||
return;
|
||
}
|
||
if (!isActive) {
|
||
cancelAnimation(fill);
|
||
fill.value = 0;
|
||
return;
|
||
}
|
||
if (paused) {
|
||
cancelAnimation(fill);
|
||
return;
|
||
}
|
||
fill.value = withTiming(progress, {
|
||
duration: SMOOTHING_MS,
|
||
easing: Easing.linear,
|
||
});
|
||
}, [isPast, isActive, progress, paused, fill]);
|
||
|
||
const fillStyle = useAnimatedStyle(() => ({
|
||
width: `${Math.min(Math.max(fill.value, 0), 1) * 100}%`,
|
||
}));
|
||
|
||
return (
|
||
<View
|
||
className="flex-1 overflow-hidden rounded-full bg-white/30"
|
||
style={{ height: SEGMENT_HEIGHT }}
|
||
>
|
||
<Animated.View
|
||
className="h-full bg-white/95 rounded-full"
|
||
style={fillStyle}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|