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