feat: show human playback markers on timeline

Resolves #105
This commit is contained in:
talksik
2026-04-01 11:02:48 -07:00
parent 4d1ad717ad
commit 7c77d02ab5
6 changed files with 156 additions and 91 deletions
+22 -52
View File
@@ -20,6 +20,7 @@ import { WindowControls } from "@/components/window-controls";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useStreamPlayback } from "@/hooks/use-stream-playback";
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
import { usePresencePositions } from "@/hooks/use-presence-positions";
import { getInitials } from "@/lib/utils";
function getParticleDisplayName(particle: Particle): string {
@@ -119,6 +120,15 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
usePrefetchAdjacentMedia(children, currentIndex);
const authedUser = useAuthStore((s) => s.user);
const network = useNetwork(networkId);
const presenceBySegment = usePresencePositions(
streamParticle.playback_markers,
children,
network?.humans,
authedUser?.id,
);
const [composeActive, setComposeActive] = useState(false);
const [progress, setProgress] = useState(0);
@@ -268,16 +278,6 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
{/* Bottom gradient safe zone — keeps captions & controls readable */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-48 bg-gradient-to-t from-black/60 to-transparent" />
{/* Progress indicator */}
<div className="z-10 absolute left-0 right-0">
<PlaybackPageIndicator
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
/>
</div>
<div className="z-10 absolute left-0 right-0 mt-3">
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
</div>
@@ -303,9 +303,6 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
{/* Bottom-left: metadata */}
<div className="absolute bottom-0 left-0 z-10 flex items-center gap-2 px-2 pb-2 text-xs text-white/70">
{currentParticle && (
<SeenIndicator stream={streamParticle} currentParticle={currentParticle} networkId={networkId} />
)}
{exitRemainingMs !== null && (
<span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
@@ -324,6 +321,17 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
</span>
</ControlsIndicator>
</div>
{/* Progress indicator — bottom */}
<div className="z-10 absolute inset-x-0 bottom-0 mb-12">
<PlaybackPageIndicator
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
/>
</div>
</div>
);
}
@@ -388,7 +396,7 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{human?.email_prefix ?? humanId}</TooltipContent>
<TooltipContent>{human?.email ?? humanId}</TooltipContent>
</Tooltip>
);
})}
@@ -446,41 +454,3 @@ function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle
)
}
// Shows a list of avatars of users who have seen the current particle, based on playback markers in the stream particle.
const SeenIndicator = ({ stream, currentParticle, networkId }: { stream: Particle & { type: "stream" }, currentParticle: Particle, networkId: string }) => {
const authedUser = useAuthStore((s) => s.user);
const network = useNetwork(networkId);
const playbackMarkers = stream.playback_markers ?? {};
const seenUserIds = Object.entries(playbackMarkers)
.filter(([userId, timestamp]) => timestamp.getTime() >= currentParticle.created_at.getTime() && userId !== authedUser?.id)
.map(([userId, _]) => userId);
const seenHumans = seenUserIds
.map((userId) => network?.humans?.find((h) => h.id === userId))
.filter((h): h is NonNullable<typeof h> => !!h && h.id !== currentParticle.created_by_human_id);
if (seenHumans.length === 0) return null;
return (
<>
{"Seen by"}
<AvatarGroup>
{seenHumans.map((human) => (
<Tooltip key={human.id}>
<TooltipTrigger asChild>
<Avatar size="sm">
<AvatarFallback>
{human.email_prefix.slice(0, 2)}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
<p>Seen by {human.email_prefix}</p>
</TooltipContent>
</Tooltip>
))}
</AvatarGroup>
</>
);
}