From e5d1349ad75928787c2feeb3b487ea4cfb16902b Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 28 May 2026 10:12:41 -0700 Subject: [PATCH] feat: paginate stream page indicator Closes #221 --- .../particles/playback-page-indicator.tsx | 142 +++++++++++++----- .../stream-view/PlaybackPageIndicator.tsx | 74 +++++++-- 2 files changed, 169 insertions(+), 47 deletions(-) diff --git a/js/desktop/src/features/particles/playback-page-indicator.tsx b/js/desktop/src/features/particles/playback-page-indicator.tsx index 89b4c6f..c9f215d 100644 --- a/js/desktop/src/features/particles/playback-page-indicator.tsx +++ b/js/desktop/src/features/particles/playback-page-indicator.tsx @@ -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 ( -
- {Array.from({ length: total }, (_, i) => { - const presence = presenceBySegment?.get(i); - return ( -
- {showAvatars && presence && presence.length > 0 && ( - - )} - {showTracks && ( - - )} -
- ); - })} +
+
+ {paginated && ( + onGoTo(pageStart - 1)} + /> + )} +
+ {Array.from({ length: visibleCount }, (_, j) => { + const i = pageStart + j; + const presence = presenceBySegment?.get(i); + return ( +
+ {showAvatars && presence && presence.length > 0 && ( + + )} + {showTracks && ( + + )} +
+ ); + })} +
+ {paginated && ( + onGoTo(pageStart + PAGE_SIZE)} + /> + )} +
+ {paginated && showTracks && current >= 0 && ( +
+ {current + 1} / {total} +
+ )}
); } +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
; + } + if (!interactive) { + // Avatars layer: invisible spacer matching the tracks layer's stub width. + return
; + } + return ( + + ); +} + function SegmentPresenceAvatars({ presence, onlineHumanIds, diff --git a/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx b/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx index 70caa4f..de09509 100644 --- a/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx +++ b/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx @@ -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 ( - - {Array.from({ length: total }).map((_, i) => ( - - ))} + + + {paginated && } + + {Array.from({ length: visibleCount }).map((_, j) => { + const i = pageStart + j; + return ( + + ); + })} + + {paginated && } + + {paginated && current >= 0 && ( + + {current + 1} / {total} + + )} ); } +function GhostStub({ visible }: { visible: boolean }) { + // Always reserve width so segments don't shift when stubs appear/disappear. + if (!visible) { + return ; + } + return ( + + ); +} + interface SegmentProps { isActive: boolean; isPast: boolean;