418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { RoomEvent, Track } from 'livekit-client';
|
|
import {
|
|
ArrowLeftIcon,
|
|
CheckIcon,
|
|
CircleDotIcon,
|
|
MonitorUpIcon,
|
|
RadioTowerIcon,
|
|
SparklesIcon,
|
|
Volume2Icon,
|
|
XIcon,
|
|
} from 'lucide-react';
|
|
import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client';
|
|
import type { Pod } from '@podman/shared';
|
|
import { startBeat, type BeatHandle } from '../lib/beat.js';
|
|
import { useInterventions } from '../livekit/useInterventions.js';
|
|
import LiveWaveform from '@/components/ruixen/live-waveform';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import {
|
|
Empty,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
} from '@/components/ui/empty';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface PInfo {
|
|
id: string;
|
|
name: string;
|
|
isLocal: boolean;
|
|
speaking: boolean;
|
|
}
|
|
|
|
function snapshot(room: Room, fallbackName: string): PInfo[] {
|
|
const lp = room.localParticipant;
|
|
const local: PInfo = {
|
|
id: lp.identity,
|
|
name: lp.name || fallbackName,
|
|
isLocal: true,
|
|
speaking: lp.isSpeaking,
|
|
};
|
|
const remotes = Array.from(room.remoteParticipants.values()).map((p) => ({
|
|
id: p.identity,
|
|
name: p.name || p.identity,
|
|
isLocal: false,
|
|
speaking: p.isSpeaking,
|
|
}));
|
|
return [local, ...remotes];
|
|
}
|
|
|
|
export function PodView({
|
|
team,
|
|
me,
|
|
room,
|
|
devMode,
|
|
onLeave,
|
|
}: {
|
|
team: Pod;
|
|
me: string;
|
|
room: Room | null;
|
|
devMode: boolean;
|
|
onLeave: () => void;
|
|
}) {
|
|
const [participants, setParticipants] = useState<PInfo[]>([]);
|
|
const [sharing, setSharing] = useState(false);
|
|
const [playingBeat, setPlayingBeat] = useState(false);
|
|
const [note, setNote] = useState<string | null>(null);
|
|
const { active, respond } = useInterventions(room);
|
|
|
|
const audioRef = useRef<HTMLDivElement>(null);
|
|
const beatRef = useRef<BeatHandle | null>(null);
|
|
const screenTrackRef = useRef<MediaStreamTrack | null>(null);
|
|
const onLeaveRef = useRef(onLeave);
|
|
onLeaveRef.current = onLeave;
|
|
|
|
useEffect(() => {
|
|
if (!room) return;
|
|
const refresh = () => setParticipants(snapshot(room, me));
|
|
refresh();
|
|
|
|
const onAudio = (track: RemoteTrack, _pub: RemoteTrackPublication, _p: RemoteParticipant) => {
|
|
if (track.kind === Track.Kind.Audio && audioRef.current) {
|
|
audioRef.current.appendChild(track.attach());
|
|
}
|
|
};
|
|
const onAudioGone = (track: RemoteTrack) => track.detach().forEach((el) => el.remove());
|
|
const onDisconnected = () => onLeaveRef.current();
|
|
|
|
room
|
|
.on(RoomEvent.ParticipantConnected, refresh)
|
|
.on(RoomEvent.ParticipantDisconnected, refresh)
|
|
.on(RoomEvent.ActiveSpeakersChanged, refresh)
|
|
.on(RoomEvent.TrackSubscribed, onAudio)
|
|
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
|
.on(RoomEvent.Disconnected, onDisconnected);
|
|
|
|
return () => {
|
|
room
|
|
.off(RoomEvent.ParticipantConnected, refresh)
|
|
.off(RoomEvent.ParticipantDisconnected, refresh)
|
|
.off(RoomEvent.ActiveSpeakersChanged, refresh)
|
|
.off(RoomEvent.TrackSubscribed, onAudio)
|
|
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
|
.off(RoomEvent.Disconnected, onDisconnected);
|
|
};
|
|
}, [room, me]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
beatRef.current?.stop();
|
|
beatRef.current = null;
|
|
screenTrackRef.current?.stop();
|
|
screenTrackRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
async function toggleBeat() {
|
|
if (!room) return;
|
|
setNote(null);
|
|
try {
|
|
if (playingBeat) {
|
|
if (beatRef.current) await room.localParticipant.unpublishTrack(beatRef.current.track);
|
|
beatRef.current?.stop();
|
|
beatRef.current = null;
|
|
setPlayingBeat(false);
|
|
} else {
|
|
await room.startAudio().catch(() => {});
|
|
const handle = startBeat();
|
|
beatRef.current = handle;
|
|
await room.localParticipant.publishTrack(handle.track, { name: 'podman-beat' });
|
|
setPlayingBeat(true);
|
|
}
|
|
} catch (e) {
|
|
setNote(`Audio test failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async function toggleScreen() {
|
|
if (!room) return;
|
|
setNote(null);
|
|
try {
|
|
if (sharing) {
|
|
if (screenTrackRef.current)
|
|
await room.localParticipant.unpublishTrack(screenTrackRef.current);
|
|
screenTrackRef.current?.stop();
|
|
screenTrackRef.current = null;
|
|
setSharing(false);
|
|
return;
|
|
}
|
|
if (!window.isSecureContext || !navigator.mediaDevices?.getDisplayMedia) {
|
|
setNote('Screen capture needs HTTPS.');
|
|
return;
|
|
}
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
|
const track = stream.getVideoTracks()[0];
|
|
if (!track) return;
|
|
track.onended = () => {
|
|
screenTrackRef.current = null;
|
|
setSharing(false);
|
|
};
|
|
await room.localParticipant.publishTrack(track, { source: Track.Source.ScreenShare });
|
|
screenTrackRef.current = track;
|
|
setSharing(true);
|
|
} catch (e) {
|
|
setNote(`Screen share stopped: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background text-foreground">
|
|
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col gap-5 px-4 py-4 sm:px-6 lg:px-8">
|
|
<header className="sticky top-0 z-10 -mx-4 flex flex-col gap-3 border-b bg-background/86 px-4 pb-4 pt-2 backdrop-blur-xl sm:-mx-6 sm:px-6 md:flex-row md:items-center md:justify-between lg:-mx-8 lg:px-8">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="icon" onClick={onLeave}>
|
|
<ArrowLeftIcon />
|
|
<span className="sr-only">Leave pod</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Leave pod</TooltipContent>
|
|
</Tooltip>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="truncate text-xl font-semibold tracking-tight">{team.name}</h1>
|
|
<Badge variant={room ? 'default' : 'secondary'} className="rounded-md">
|
|
{room ? 'live' : 'local'}
|
|
</Badge>
|
|
</div>
|
|
<p className="truncate font-mono text-xs text-muted-foreground">{team.repo}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
|
|
<Button variant="outline" onClick={toggleBeat} disabled={!room}>
|
|
<Volume2Icon data-icon="inline-start" />
|
|
{playingBeat ? 'Stop audio' : 'Test audio'}
|
|
</Button>
|
|
<Button onClick={toggleScreen} disabled={!room}>
|
|
<MonitorUpIcon data-icon="inline-start" />
|
|
{sharing ? 'Stop sharing' : 'Share screen'}
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
{devMode && (
|
|
<Alert>
|
|
<RadioTowerIcon />
|
|
<AlertTitle>Local mode</AlertTitle>
|
|
<AlertDescription>LiveKit is not configured for this session.</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{note && (
|
|
<Alert>
|
|
<CircleDotIcon />
|
|
<AlertTitle>Room notice</AlertTitle>
|
|
<AlertDescription>{note}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<main className="grid flex-1 gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
|
|
<section className="flex min-w-0 flex-col gap-5">
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<Metric label="Participants" value={participants.length || 1} />
|
|
<Metric label="Screen" value={sharing ? 'sharing' : 'idle'} />
|
|
<Metric label="PodMan" value={podmanPresent ? 'online' : 'waiting'} />
|
|
</div>
|
|
|
|
<Card className="flex-1">
|
|
<CardHeader>
|
|
<CardTitle>Room state</CardTitle>
|
|
<CardDescription>People and media currently visible to PodMan.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{participants.length === 0 ? (
|
|
<Empty className="min-h-80">
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<RadioTowerIcon />
|
|
</EmptyMedia>
|
|
<EmptyTitle>Connecting</EmptyTitle>
|
|
<EmptyDescription>Waiting for LiveKit room state.</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
) : (
|
|
<div className="grid gap-2 md:grid-cols-2">
|
|
{participants.map((p) => (
|
|
<Participant key={p.id} participant={p} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
<CardFooter>
|
|
<p className="truncate text-sm text-muted-foreground">
|
|
Roster: {team.members.join(', ') || 'No saved members'}
|
|
</p>
|
|
</CardFooter>
|
|
</Card>
|
|
</section>
|
|
|
|
<aside className="flex flex-col gap-5">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Intervention</CardTitle>
|
|
<CardDescription>Card first, voice only for urgent escalation.</CardDescription>
|
|
<CardAction>
|
|
<Badge variant={active ? 'default' : 'secondary'} className="rounded-md">
|
|
{active ? 'active' : 'clear'}
|
|
</Badge>
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{active ? (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="rounded-lg border bg-muted/35 p-3">
|
|
<p className="text-sm leading-6">{active.message}</p>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 text-sm">
|
|
<span className="text-muted-foreground">Suggested action</span>
|
|
<Badge variant="outline">
|
|
{active.suggestedAction.kind.replaceAll('_', ' ')}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Empty className="min-h-72 border-0 p-0">
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<SparklesIcon />
|
|
</EmptyMedia>
|
|
<EmptyTitle>No collision detected</EmptyTitle>
|
|
<EmptyDescription>
|
|
Share your screen when ready. PodMan will stay quiet until there is a useful
|
|
signal.
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
)}
|
|
</CardContent>
|
|
{active && (
|
|
<CardFooter className="justify-end gap-2">
|
|
<Button variant="outline" onClick={() => void respond('dismissed', false)}>
|
|
<XIcon data-icon="inline-start" />
|
|
Dismiss
|
|
</Button>
|
|
<Button onClick={() => void respond('accepted', true)}>
|
|
<CheckIcon data-icon="inline-start" />
|
|
Accept
|
|
</Button>
|
|
</CardFooter>
|
|
)}
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Status</CardTitle>
|
|
<CardDescription>Connection, media, and agent presence.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<LiveWaveform
|
|
processing={!!room}
|
|
active={false}
|
|
height={32}
|
|
barWidth={2}
|
|
barGap={3}
|
|
className="mb-4 px-3 py-2 text-muted-foreground"
|
|
/>
|
|
<div className="flex flex-col gap-3">
|
|
<StatusLine label="LiveKit" value={room ? 'connected' : 'offline'} />
|
|
<StatusLine label="Screen" value={sharing ? 'published' : 'not shared'} />
|
|
<StatusLine label="Audio" value={playingBeat ? 'publishing' : 'ready'} />
|
|
<StatusLine label="Agent" value={podmanPresent ? 'watching' : 'waiting'} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</aside>
|
|
</main>
|
|
<div ref={audioRef} className="hidden" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Metric({ label, value }: { label: string; value: number | string }) {
|
|
return (
|
|
<Card size="sm">
|
|
<CardContent>
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className="mt-1 text-lg font-medium">{value}</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function Participant({ participant }: { participant: PInfo }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex min-h-16 items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 transition',
|
|
participant.speaking && 'bg-muted',
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Avatar>
|
|
<AvatarFallback>{initials(participant.name)}</AvatarFallback>
|
|
{participant.speaking && <AvatarBadge />}
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">{participant.name}</p>
|
|
<p className="text-xs text-muted-foreground">{participant.isLocal ? 'you' : 'remote'}</p>
|
|
</div>
|
|
</div>
|
|
<Badge variant={participant.speaking ? 'default' : 'secondary'} className="rounded-md">
|
|
{participant.speaking ? 'speaking' : 'connected'}
|
|
</Badge>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusLine({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-sm text-muted-foreground">{label}</span>
|
|
<span className="text-sm font-medium">{value}</span>
|
|
</div>
|
|
<Separator />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function initials(name: string): string {
|
|
return name
|
|
.split(/\s+/)
|
|
.map((w) => w[0] ?? '')
|
|
.join('')
|
|
.slice(0, 2)
|
|
.toUpperCase();
|
|
}
|