@@ -8,14 +8,14 @@ function Avatar({
|
|||||||
size = "default",
|
size = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
size?: "default" | "sm" | "lg"
|
size?: "default" | "xs" | "sm" | "lg"
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
data-slot="avatar"
|
data-slot="avatar"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
|
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 data-[size=xs]:size-4 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -47,7 +47,7 @@ function AvatarFallback({
|
|||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
data-slot="avatar-fallback"
|
data-slot="avatar-fallback"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
|
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs group-data-[size=xs]/avatar:text-[8px]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -61,6 +61,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
|||||||
data-slot="avatar-badge"
|
data-slot="avatar-badge"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
||||||
|
"group-data-[size=xs]/avatar:size-1.5 group-data-[size=xs]/avatar:[&>svg]:hidden",
|
||||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
@@ -91,7 +92,7 @@ function AvatarGroupCount({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="avatar-group-count"
|
data-slot="avatar-group-count"
|
||||||
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
|
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=xs]/avatar-group:size-4 group-has-data-[size=xs]/avatar-group:text-[8px] [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function MediaParticleView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{audioSource && (
|
{audioSource && (
|
||||||
<div className="z-10 absolute bottom-10">
|
<div className="z-10 absolute bottom-20">
|
||||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { HumanPresence } from "@/hooks/use-presence-positions";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_AVATARS = 3;
|
||||||
|
|
||||||
interface PlaybackPageIndicatorProps {
|
interface PlaybackPageIndicatorProps {
|
||||||
total: number;
|
total: number;
|
||||||
current: number;
|
current: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
onGoTo: (index: number) => void;
|
onGoTo: (index: number) => void;
|
||||||
|
presenceBySegment?: Map<number, HumanPresence[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaybackPageIndicator({
|
export function PlaybackPageIndicator({
|
||||||
@@ -12,45 +22,79 @@ export function PlaybackPageIndicator({
|
|||||||
current,
|
current,
|
||||||
progress,
|
progress,
|
||||||
onGoTo,
|
onGoTo,
|
||||||
|
presenceBySegment,
|
||||||
}: PlaybackPageIndicatorProps) {
|
}: PlaybackPageIndicatorProps) {
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center gap-0.5 px-1">
|
<div className="flex w-full items-end gap-px leading-none">
|
||||||
{Array.from({ length: total }, (_, i) => (
|
{Array.from({ length: total }, (_, i) => {
|
||||||
<button
|
const presence = presenceBySegment?.get(i);
|
||||||
key={i}
|
return (
|
||||||
onClick={(e) => {
|
<div key={i} className="flex flex-1 flex-col items-stretch">
|
||||||
e.stopPropagation();
|
{/* Presence avatars above the track */}
|
||||||
onGoTo(i);
|
{presence && presence.length > 0 && (
|
||||||
}}
|
<SegmentPresenceAvatars presence={presence} />
|
||||||
className="group relative h-3 flex-1"
|
|
||||||
>
|
|
||||||
{/* Dim track */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-x-0 top-0 h-1 bg-white/30 transition-all",
|
|
||||||
"group-hover:h-1.5 group-hover:top-0.5",
|
|
||||||
)}
|
)}
|
||||||
/>
|
<button
|
||||||
{/* Fill */}
|
onClick={(e) => {
|
||||||
<div
|
e.stopPropagation();
|
||||||
className={cn(
|
onGoTo(i);
|
||||||
"absolute left-0 top-0 h-1 bg-white/90 transition-all",
|
}}
|
||||||
"group-hover:h-1.5 group-hover:top-0.5",
|
className="group relative block h-3 w-full"
|
||||||
)}
|
>
|
||||||
style={{
|
{/* Dim track */}
|
||||||
width:
|
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
|
||||||
i < current
|
{/* Fill */}
|
||||||
? "100%"
|
<div
|
||||||
: i === current
|
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
|
||||||
? `${progress * 100}%`
|
style={{
|
||||||
: "0%",
|
width:
|
||||||
transition: i === current ? "width 300ms linear" : "none",
|
i < current
|
||||||
}}
|
? "100%"
|
||||||
/>
|
: i === current
|
||||||
</button>
|
? `${progress * 100}%`
|
||||||
|
: "0%",
|
||||||
|
transition: i === current ? "width 300ms linear" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentPresenceAvatars({
|
||||||
|
presence,
|
||||||
|
}: {
|
||||||
|
presence: HumanPresence[];
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
<Avatar size="xs" className="ring-1 ring-black/50">
|
||||||
|
<AvatarFallback>
|
||||||
|
{human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { WindowControls } from "@/components/window-controls";
|
|||||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||||
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
||||||
|
import { usePresencePositions } from "@/hooks/use-presence-positions";
|
||||||
import { getInitials } from "@/lib/utils";
|
import { getInitials } from "@/lib/utils";
|
||||||
|
|
||||||
function getParticleDisplayName(particle: Particle): string {
|
function getParticleDisplayName(particle: Particle): string {
|
||||||
@@ -119,6 +120,15 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
usePrefetchAdjacentMedia(children, currentIndex);
|
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 [composeActive, setComposeActive] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
@@ -268,16 +278,6 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
{/* Bottom gradient safe zone — keeps captions & controls readable */}
|
{/* 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" />
|
<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">
|
<div className="z-10 absolute left-0 right-0 mt-3">
|
||||||
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
|
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
|
||||||
</div>
|
</div>
|
||||||
@@ -303,9 +303,6 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
{/* Bottom-left: metadata */}
|
{/* 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">
|
<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 && (
|
{exitRemainingMs !== null && (
|
||||||
<span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm">
|
<span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm">
|
||||||
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
||||||
@@ -324,6 +321,17 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
</span>
|
</span>
|
||||||
</ControlsIndicator>
|
</ControlsIndicator>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -388,7 +396,7 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{human?.email_prefix ?? humanId}</TooltipContent>
|
<TooltipContent>{human?.email ?? humanId}</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function TranscriptOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className={centered
|
<div className={centered
|
||||||
? "absolute inset-0 flex items-center justify-center px-6"
|
? "absolute inset-0 flex items-center justify-center px-6"
|
||||||
: "absolute bottom-6 left-0 right-0 flex justify-center px-6"
|
: "absolute bottom-17 left-0 right-0 flex justify-center px-6"
|
||||||
}>
|
}>
|
||||||
<p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
|
<p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
|
||||||
{activeChunk.map((word, i) => {
|
{activeChunk.map((word, i) => {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { Human, Particle } from "@/api/types";
|
||||||
|
|
||||||
|
export interface HumanPresence {
|
||||||
|
humanId: string;
|
||||||
|
email: string;
|
||||||
|
emailPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps playback markers to segment indices, returning which users are present at each particle.
|
||||||
|
*/
|
||||||
|
export function usePresencePositions(
|
||||||
|
playbackMarkers: Record<string, Date> | undefined,
|
||||||
|
children: Particle[],
|
||||||
|
networkHumans: Human[] | undefined,
|
||||||
|
currentUserId: string | undefined,
|
||||||
|
): Map<number, HumanPresence[]> {
|
||||||
|
return useMemo(() => {
|
||||||
|
const result = new Map<number, HumanPresence[]>();
|
||||||
|
if (!playbackMarkers || !networkHumans || children.length === 0) return result;
|
||||||
|
|
||||||
|
for (const [userId, markerTimestamp] of Object.entries(playbackMarkers)) {
|
||||||
|
if (userId === currentUserId) continue;
|
||||||
|
|
||||||
|
const human = networkHumans.find((h) => h.id === userId);
|
||||||
|
if (!human) continue;
|
||||||
|
|
||||||
|
// Find the last particle whose created_at <= marker timestamp
|
||||||
|
let segmentIndex = -1;
|
||||||
|
for (let i = children.length - 1; i >= 0; i--) {
|
||||||
|
if (children[i].created_at.getTime() <= markerTimestamp.getTime()) {
|
||||||
|
segmentIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (segmentIndex === -1) continue;
|
||||||
|
|
||||||
|
const existing = result.get(segmentIndex);
|
||||||
|
const presence: HumanPresence = { humanId: human.id, email: human.email, emailPrefix: human.email_prefix };
|
||||||
|
if (existing) {
|
||||||
|
existing.push(presence);
|
||||||
|
} else {
|
||||||
|
result.set(segmentIndex, [presence]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [playbackMarkers, children, networkHumans, currentUserId]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user