@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import Animated, {
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
@@ -19,11 +20,18 @@ interface PlaybackPageIndicatorProps {
|
||||
const SEGMENT_GAP = 3;
|
||||
const SEGMENT_HEIGHT = 2.5;
|
||||
const SMOOTHING_MS = 300;
|
||||
const PAGE_SIZE = 10;
|
||||
const STUB_WIDTH = 8;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Caps the visible window at PAGE_SIZE segments. When more particles exist
|
||||
* before/after the window, a short dim "ghost stub" appears on that side using
|
||||
* the same track vocabulary. Non-interactive on mobile — the window slides
|
||||
* automatically as `current` crosses page boundaries.
|
||||
*/
|
||||
export function PlaybackPageIndicator({
|
||||
total,
|
||||
@@ -33,21 +41,65 @@ export function PlaybackPageIndicator({
|
||||
}: PlaybackPageIndicatorProps) {
|
||||
if (total === 0) return null;
|
||||
|
||||
const paginated = total > PAGE_SIZE;
|
||||
const safeCurrent = current < 0 ? 0 : current;
|
||||
const pageStart = paginated
|
||||
? Math.floor(safeCurrent / PAGE_SIZE) * PAGE_SIZE
|
||||
: 0;
|
||||
const visibleCount = paginated
|
||||
? Math.min(PAGE_SIZE, total - pageStart)
|
||||
: total;
|
||||
const hasPrevPage = paginated && pageStart > 0;
|
||||
const hasNextPage = paginated && pageStart + PAGE_SIZE < total;
|
||||
|
||||
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 className="items-stretch">
|
||||
<View className="flex-row items-stretch" style={{ gap: SEGMENT_GAP }}>
|
||||
{paginated && <GhostStub visible={hasPrevPage} />}
|
||||
<View
|
||||
className="flex-1 flex-row items-stretch"
|
||||
style={{ gap: SEGMENT_GAP }}
|
||||
>
|
||||
{Array.from({ length: visibleCount }).map((_, j) => {
|
||||
const i = pageStart + j;
|
||||
return (
|
||||
<Segment
|
||||
key={i}
|
||||
isActive={i === current}
|
||||
isPast={i < current}
|
||||
progress={progress}
|
||||
paused={paused}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{paginated && <GhostStub visible={hasNextPage} />}
|
||||
</View>
|
||||
{paginated && current >= 0 && (
|
||||
<Text
|
||||
className="pt-1 text-center font-medium text-white/40"
|
||||
style={{ fontSize: 10, fontVariant: ["tabular-nums"] }}
|
||||
>
|
||||
{current + 1} / {total}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function GhostStub({ visible }: { visible: boolean }) {
|
||||
// Always reserve width so segments don't shift when stubs appear/disappear.
|
||||
if (!visible) {
|
||||
return <View style={{ width: STUB_WIDTH }} />;
|
||||
}
|
||||
return (
|
||||
<View
|
||||
className="overflow-hidden rounded-full bg-white/15"
|
||||
style={{ width: STUB_WIDTH, height: SEGMENT_HEIGHT, alignSelf: "flex-end" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SegmentProps {
|
||||
isActive: boolean;
|
||||
isPast: boolean;
|
||||
|
||||
Reference in New Issue
Block a user