feat: paginate stream page indicator

Closes #221
This commit is contained in:
Arjun Patel
2026-05-28 10:12:41 -07:00
parent 563c91e7d5
commit e5d1349ad7
2 changed files with 169 additions and 47 deletions
@@ -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;