Files
llink/js/desktop/src/features/particles/playback-page-indicator.tsx
T
2026-06-11 14:49:48 -07:00

186 lines
5.6 KiB
TypeScript

import { HumanAvatar } from '@/components/human-avatar';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { HumanPresence } from '@/hooks/use-presence-positions';
const MAX_VISIBLE_AVATARS = 3;
const PAGE_SIZE = 10;
interface PlaybackPageIndicatorProps {
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment?: Map<number, HumanPresence[]>;
/** Set of humanIds currently online in the stream channel. */
onlineHumanIds?: Set<string>;
/** Render only avatars or only tracks. Omit to render both. */
layer?: 'avatars' | 'tracks';
}
export function PlaybackPageIndicator({
total,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
layer,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
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 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,
}: {
presence: HumanPresence[];
onlineHumanIds?: Set<string>;
}) {
const visible = presence.slice(0, MAX_VISIBLE_AVATARS);
const overflow = presence.length - MAX_VISIBLE_AVATARS;
return (
<div className="flex items-center justify-center -space-x-1.5 pb-0.5">
{visible.map((human) => (
<Tooltip key={human.humanId}>
<TooltipTrigger asChild>
<HumanAvatar
size="xs"
className={
onlineHumanIds?.has(human.humanId)
? 'ring-2 ring-green-500'
: 'ring-1 ring-black/50'
}
avatarObjectId={human.avatarObjectId}
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{human.email}
</TooltipContent>
</Tooltip>
))}
{overflow > 0 && (
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
)}
</div>
);
}