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"; import type { HumanPresence } from "@/hooks/use-presence-positions";
const MAX_VISIBLE_AVATARS = 3; const MAX_VISIBLE_AVATARS = 3;
const PAGE_SIZE = 10;
interface PlaybackPageIndicatorProps { interface PlaybackPageIndicatorProps {
total: number; total: number;
@@ -34,47 +35,116 @@ export function PlaybackPageIndicator({
const showAvatars = layer !== "tracks"; const showAvatars = layer !== "tracks";
const showTracks = layer !== "avatars"; 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 ( return (
<div className="flex w-full items-end gap-px leading-none"> <div className="flex w-full flex-col items-stretch leading-none">
{Array.from({ length: total }, (_, i) => { <div className="flex w-full items-end gap-px">
const presence = presenceBySegment?.get(i); {paginated && (
return ( <GhostStub
<div key={i} className="flex flex-1 flex-col items-stretch"> visible={hasPrevPage}
{showAvatars && presence && presence.length > 0 && ( interactive={showTracks}
<SegmentPresenceAvatars presence={presence} onlineHumanIds={onlineHumanIds} /> onClick={() => onGoTo(pageStart - 1)}
)} />
{showTracks && ( )}
<button <div className="flex flex-1 items-end gap-px">
onClick={(e) => { {Array.from({ length: visibleCount }, (_, j) => {
e.stopPropagation(); const i = pageStart + j;
onGoTo(i); const presence = presenceBySegment?.get(i);
}} return (
className="group relative block h-3 w-full" <div key={i} className="flex flex-1 flex-col items-stretch">
> {showAvatars && presence && presence.length > 0 && (
{/* Dim track */} <SegmentPresenceAvatars
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" /> presence={presence}
{/* Fill */} onlineHumanIds={onlineHumanIds}
<div />
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5" )}
style={{ {showTracks && (
width: <button
i < current onClick={(e) => {
? "100%" e.stopPropagation();
: i === current onGoTo(i);
? `${progress * 100}%` }}
: "0%", className="group relative block h-3 w-full"
transition: i === current ? "width 300ms linear" : "none", >
}} {/* Dim track */}
/> <div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
</button> {/* Fill */}
)} <div
</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> </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({ function SegmentPresenceAvatars({
presence, presence,
onlineHumanIds, onlineHumanIds,
@@ -1,5 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { View } from "react-native"; import { Text, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
cancelAnimation, cancelAnimation,
@@ -19,11 +20,18 @@ interface PlaybackPageIndicatorProps {
const SEGMENT_GAP = 3; const SEGMENT_GAP = 3;
const SEGMENT_HEIGHT = 2.5; const SEGMENT_HEIGHT = 2.5;
const SMOOTHING_MS = 300; const SMOOTHING_MS = 300;
const PAGE_SIZE = 10;
const STUB_WIDTH = 8;
/** /**
* Snapchat-style segmented progress bar. Past segments full, future empty, * Snapchat-style segmented progress bar. Past segments full, future empty,
* active segment animated. The 300ms linear smoothing absorbs the 100ms * active segment animated. The 300ms linear smoothing absorbs the 100ms
* tick from the particle view source so motion looks continuous at 60fps. * 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({ export function PlaybackPageIndicator({
total, total,
@@ -33,21 +41,65 @@ export function PlaybackPageIndicator({
}: PlaybackPageIndicatorProps) { }: PlaybackPageIndicatorProps) {
if (total === 0) return null; 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 ( return (
<View className="flex-row items-stretch" style={{ gap: SEGMENT_GAP }}> <View className="items-stretch">
{Array.from({ length: total }).map((_, i) => ( <View className="flex-row items-stretch" style={{ gap: SEGMENT_GAP }}>
<Segment {paginated && <GhostStub visible={hasPrevPage} />}
key={i} <View
isActive={i === current} className="flex-1 flex-row items-stretch"
isPast={i < current} style={{ gap: SEGMENT_GAP }}
progress={progress} >
paused={paused} {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> </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 { interface SegmentProps {
isActive: boolean; isActive: boolean;
isPast: boolean; isPast: boolean;