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;