Files
podman/frontend/src/components/PodView.tsx
T
2026-06-28 01:44:26 +00:00

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();
}