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
@@ -7,6 +7,7 @@ import {
import type { HumanPresence } from "@/hooks/use-presence-positions";
const MAX_VISIBLE_AVATARS = 3;
const PAGE_SIZE = 10;
interface PlaybackPageIndicatorProps {
total: number;
@@ -34,47 +35,116 @@ export function PlaybackPageIndicator({
const showAvatars = layer !== "tracks";
const showTracks = layer !== "avatars";
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 (
<div className="flex w-full items-end gap-px leading-none">
{Array.from({ length: total }, (_, i) => {
const presence = presenceBySegment?.get(i);
return (
<div key={i} className="flex flex-1 flex-col items-stretch">
{showAvatars && presence && presence.length > 0 && (
<SegmentPresenceAvatars presence={presence} onlineHumanIds={onlineHumanIds} />
)}
{showTracks && (
<button
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
className="group relative block h-3 w-full"
>
{/* Dim track */}
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
{/* Fill */}
<div
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
}}
/>
</button>
)}
</div>
);
})}
<div className="flex w-full flex-col items-stretch leading-none">
<div className="flex w-full items-end gap-px">
{paginated && (
<GhostStub
visible={hasPrevPage}
interactive={showTracks}
onClick={() => onGoTo(pageStart - 1)}
/>
)}
<div className="flex flex-1 items-end gap-px">
{Array.from({ length: visibleCount }, (_, j) => {
const i = pageStart + j;
const presence = presenceBySegment?.get(i);
return (
<div key={i} className="flex flex-1 flex-col items-stretch">
{showAvatars && presence && presence.length > 0 && (
<SegmentPresenceAvatars
presence={presence}
onlineHumanIds={onlineHumanIds}
/>
)}
{showTracks && (
<button
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
className="group relative block h-3 w-full"
>
{/* Dim track */}
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
{/* Fill */}
<div
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
}}
/>
</button>
)}
</div>
);
})}
</div>
{paginated && (
<GhostStub
visible={hasNextPage}
interactive={showTracks}
onClick={() => onGoTo(pageStart + PAGE_SIZE)}
/>
)}
</div>
{paginated && showTracks && current >= 0 && (
<div className="pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide text-white/40">
{current + 1} / {total}
</div>
)}
</div>
);
}
function GhostStub({
visible,
interactive,
onClick,
}: {
visible: boolean;
interactive: boolean;
onClick: () => void;
}) {
// Always reserve width so segments don't shift when stubs appear/disappear.
if (!visible) {
return <div className="h-3 w-2 shrink-0" aria-hidden />;
}
if (!interactive) {
// Avatars layer: invisible spacer matching the tracks layer's stub width.
return <div className="h-3 w-2 shrink-0" aria-hidden />;
}
return (
<button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="group relative block h-3 w-2 shrink-0"
aria-label="Jump to adjacent page"
>
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/15 transition-all group-hover:h-1.5 group-hover:bg-white/40" />
</button>
);
}
function SegmentPresenceAvatars({
presence,
onlineHumanIds,
@@ -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;