186 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|