@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user