name: video-ui description: LiveKit video UI components — room provider, participant grid, speaker view, controls bar, prejoin screen, and participant tile using @livekit/components-react and shadcn/ui. Use this skill when the user says "add video UI", "video components", "setup video-ui", "video call UI", or "participant grid". author: "@mattwoodco" version: 1.0.0 created: 2026-02-18 dependencies: [video-room, add-shadcn]
Video UI
A complete set of video conferencing UI components built on @livekit/components-react and styled with shadcn/ui. Provides a room provider, participant grid, active speaker spotlight, media controls, device selection prejoin screen, and individual participant tiles.
Prerequisites
- Next.js app with
src/directory and App Router video-roomskill installed (LiveKit server SDK, token API, types)add-shadcnskill installed (Button, Card components available)- shadcn/ui components:
button,card,select,avatar
Installation
bun add @livekit/components-react @livekit/components-styles
bunx shadcn@latest add button card select avatar
What Gets Created
src/
└── components/
└── video/
├── video-room-provider.tsx # LiveKitRoom wrapper, fetches token from /api/video/token
├── participant-grid.tsx # Grid layout using useParticipants + useTracks
├── speaker-view.tsx # Active speaker spotlight using useSpeakingParticipants
├── controls-bar.tsx # Mute / camera / screen share / leave buttons
├── prejoin-screen.tsx # Device selection + camera preview before joining
└── participant-tile.tsx # Single participant video track + name overlay + speaking indicator
Setup Steps
Step 1: Create src/components/video/video-room-provider.tsx
"use client";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { LiveKitRoom } from "@livekit/components-react";
import "@livekit/components-styles/prefabs.css";
import type { TokenResponse } from "@/lib/video/types";
type VideoRoomProviderProps = {
roomName: string;
participantName?: string;
children: ReactNode;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Error) => void;
};
export function VideoRoomProvider({
roomName,
participantName,
children,
onConnected,
onDisconnected,
onError,
}: VideoRoomProviderProps) {
const [token, setToken] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchToken = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetch("/api/video/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomName, participantName }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: "Failed to get token" }));
throw new Error((body as { error: string }).error);
}
const data: TokenResponse = await res.json();
setToken(data.token);
setUrl(data.url);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to connect";
setError(message);
onError?.(err instanceof Error ? err : new Error(message));
} finally {
setIsLoading(false);
}
}, [roomName, participantName, onError]);
useEffect(() => {
fetchToken();
}, [fetchToken]);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>Connecting to room...</span>
</div>
</div>
);
}
if (error || !token || !url) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-destructive font-medium">Failed to join room</p>
<p className="text-muted-foreground text-sm mt-1">{error ?? "Unknown error"}</p>
<button
type="button"
onClick={fetchToken}
className="mt-4 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
Retry
</button>
</div>
</div>
);
}
return (
<LiveKitRoom
token={token}
serverUrl={url}
connect={true}
onConnected={onConnected}
onDisconnected={onDisconnected}
onError={(err) => onError?.(err)}
data-lk-theme="default"
className="h-full w-full"
>
{children}
</LiveKitRoom>
);
}
Step 2: Create src/components/video/participant-tile.tsx
"use client";
import { useRef, useEffect } from "react";
import type { TrackReferenceOrPlaceholder } from "@livekit/components-react";
import { Card } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
type ParticipantTileProps = {
trackRef: TrackReferenceOrPlaceholder;
className?: string;
};
function getInitials(name: string): string {
return name
.split(" ")
.map((part) => part.charAt(0))
.join("")
.toUpperCase()
.slice(0, 2);
}
export function ParticipantTile({ trackRef, className }: ParticipantTileProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const participant = trackRef.participant;
const publication = trackRef.publication;
const track = publication?.track;
const isSpeaking = participant.isSpeaking;
const displayName = participant.name ?? participant.identity;
useEffect(() => {
if (!videoRef.current || !track) return;
track.attach(videoRef.current);
return () => {
track.detach(videoRef.current!);
};
}, [track]);
const hasVideo = track && !publication?.isMuted;
return (
<Card
className={`relative overflow-hidden bg-muted ${
isSpeaking ? "ring-2 ring-primary" : ""
} ${className ?? ""}`}
>
{hasVideo ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-muted">
<Avatar className="h-16 w-16">
<AvatarFallback className="text-lg">
{getInitials(displayName)}
</AvatarFallback>
</Avatar>
</div>
)}
{/* Name overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-3 py-1.5">
<div className="flex items-center gap-2">
{isSpeaking && (
<span className="h-2 w-2 shrink-0 rounded-full bg-green-400 animate-pulse" />
)}
<span className="truncate text-sm font-medium text-white">
{displayName}
</span>
</div>
</div>
</Card>
);
}
Step 3: Create src/components/video/participant-grid.tsx
"use client";
import { useId } from "react";
import { useTracks } from "@livekit/components-react";
import { Track } from "livekit-client";
import { ParticipantTile } from "@/components/video/participant-tile";
type ParticipantGridProps = {
className?: string;
};
function getGridCols(count: number): string {
if (count <= 1) return "grid-cols-1";
if (count <= 4) return "grid-cols-2";
if (count <= 9) return "grid-cols-3";
return "grid-cols-4";
}
export function ParticipantGrid({ className }: ParticipantGridProps) {
const gridId = useId();
const trackRefs = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ onlySubscribed: false }
);
if (trackRefs.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>Waiting for participants...</p>
</div>
);
}
const gridCols = getGridCols(trackRefs.length);
return (
<div
className={`grid gap-2 p-2 ${gridCols} auto-rows-fr ${className ?? ""}`}
>
{trackRefs.map((trackRef) => (
<ParticipantTile
key={`${gridId}-${trackRef.participant.sid}-${trackRef.source}`}
trackRef={trackRef}
className="aspect-video"
/>
))}
</div>
);
}
Step 4: Create src/components/video/speaker-view.tsx
"use client";
import { useId } from "react";
import { useTracks } from "@livekit/components-react";
import { Track } from "livekit-client";
import { ParticipantTile } from "@/components/video/participant-tile";
type SpeakerViewProps = {
className?: string;
};
export function SpeakerView({ className }: SpeakerViewProps) {
const speakerId = useId();
const trackRefs = useTracks(
[{ source: Track.Source.Camera, withPlaceholder: true }],
{ onlySubscribed: false }
);
if (trackRefs.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>Waiting for participants...</p>
</div>
);
}
// Sort so that speaking participants appear first
const sorted = [...trackRefs].sort((a, b) => {
if (a.participant.isSpeaking && !b.participant.isSpeaking) return -1;
if (!a.participant.isSpeaking && b.participant.isSpeaking) return 1;
return 0;
});
const activeSpeaker = sorted[0];
const others = sorted.slice(1);
return (
<div className={`flex h-full flex-col gap-2 p-2 ${className ?? ""}`}>
{/* Active speaker spotlight */}
<div className="flex-1 min-h-0">
<ParticipantTile
key={`${speakerId}-spotlight-${activeSpeaker.participant.sid}`}
trackRef={activeSpeaker}
className="h-full w-full"
/>
</div>
{/* Thumbnail strip */}
{others.length > 0 && (
<div className="flex h-28 shrink-0 gap-2 overflow-x-auto">
{others.map((trackRef) => (
<ParticipantTile
key={`${speakerId}-thumb-${trackRef.participant.sid}`}
trackRef={trackRef}
className="aspect-video h-full w-auto shrink-0"
/>
))}
</div>
)}
</div>
);
}
Step 5: Create src/components/video/controls-bar.tsx
"use client";
import {
useLocalParticipant,
useRoomContext,
} from "@livekit/components-react";
import { Track } from "livekit-client";
import { Button } from "@/components/ui/button";
type ControlsBarProps = {
onLeave?: () => void;
className?: string;
showScreenShare?: boolean;
};
export function ControlsBar({
onLeave,
className,
showScreenShare = true,
}: ControlsBarProps) {
const { localParticipant } = useLocalParticipant();
const room = useRoomContext();
const isMicEnabled = localParticipant.isMicrophoneEnabled;
const isCameraEnabled = localParticipant.isCameraEnabled;
const isScreenShareEnabled = localParticipant.isScreenShareEnabled;
const handleToggleMic = async () => {
await localParticipant.setMicrophoneEnabled(!isMicEnabled);
};
const handleToggleCamera = async () => {
await localParticipant.setCameraEnabled(!isCameraEnabled);
};
const handleToggleScreenShare = async () => {
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled);
};
const handleLeave = () => {
room.disconnect();
onLeave?.();
};
return (
<div
className={`flex items-center justify-center gap-3 border-t bg-background px-4 py-3 ${className ?? ""}`}
>
{/* Microphone toggle */}
<Button
variant={isMicEnabled ? "secondary" : "destructive"}
size="sm"
onClick={handleToggleMic}
title={isMicEnabled ? "Mute microphone" : "Unmute microphone"}
>
{isMicEnabled ? (
<MicOnIcon className="mr-2 h-4 w-4" />
) : (
<MicOffIcon className="mr-2 h-4 w-4" />
)}
{isMicEnabled ? "Mute" : "Unmute"}
</Button>
{/* Camera toggle */}
<Button
variant={isCameraEnabled ? "secondary" : "destructive"}
size="sm"
onClick={handleToggleCamera}
title={isCameraEnabled ? "Turn off camera" : "Turn on camera"}
>
{isCameraEnabled ? (
<CameraOnIcon className="mr-2 h-4 w-4" />
) : (
<CameraOffIcon className="mr-2 h-4 w-4" />
)}
{isCameraEnabled ? "Camera Off" : "Camera On"}
</Button>
{/* Screen share toggle */}
{showScreenShare && (
<Button
variant={isScreenShareEnabled ? "default" : "secondary"}
size="sm"
onClick={handleToggleScreenShare}
title={isScreenShareEnabled ? "Stop sharing" : "Share screen"}
>
<ScreenShareIcon className="mr-2 h-4 w-4" />
{isScreenShareEnabled ? "Stop Share" : "Share Screen"}
</Button>
)}
{/* Leave button */}
<Button
variant="destructive"
size="sm"
onClick={handleLeave}
title="Leave room"
>
<LeaveIcon className="mr-2 h-4 w-4" />
Leave
</Button>
</div>
);
}
// --- Inline SVG icons (no external dependency needed) ---
function MicOnIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
);
}
function MicOffIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}>
<line x1="2" x2="22" y1="2" y2="22" />
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
<path d="M5 10v2a7 7 0 0 0 12 5.29" />
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
);
}
function CameraOnIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
<rect x="2" y="6" width="14" height="12" rx="2" />
</svg>
);
}
function CameraOffIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}>
<line x1="2" x2="22" y1="2" y2="22" />
<path d="M21 17.09V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
<path d="M14 14.24V18a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h1.05" />
</svg>
);
}
function ScreenShareIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3" />
<path d="M8 21h8" />
<path d="M12 17v4" />
<path d="m17 8 5-5" />
<path d="M17 3h5v5" />
</svg>
);
}
function LeaveIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" x2="9" y1="12" y2="12" />
</svg>
);
}
Step 6: Create src/components/video/prejoin-screen.tsx
"use client";
import { useState, useRef, useEffect, useCallback, useId } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type PrejoinScreenProps = {
roomName: string;
onJoin: (settings: JoinSettings) => void;
className?: string;
};
type JoinSettings = {
audioDeviceId: string | undefined;
videoDeviceId: string | undefined;
audioEnabled: boolean;
videoEnabled: boolean;
};
type MediaDeviceOption = {
deviceId: string;
label: string;
};
export function PrejoinScreen({ roomName, onJoin, className }: PrejoinScreenProps) {
const audioSelectId = useId();
const videoSelectId = useId();
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [audioDevices, setAudioDevices] = useState<MediaDeviceOption[]>([]);
const [videoDevices, setVideoDevices] = useState<MediaDeviceOption[]>([]);
const [selectedAudioId, setSelectedAudioId] = useState<string | undefined>(undefined);
const [selectedVideoId, setSelectedVideoId] = useState<string | undefined>(undefined);
const [audioEnabled, setAudioEnabled] = useState(true);
const [videoEnabled, setVideoEnabled] = useState(true);
const [isLoadingDevices, setIsLoadingDevices] = useState(true);
const enumerateDevices = useCallback(async () => {
setIsLoadingDevices(true);
try {
// Request permissions first so device labels are available
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices
.filter((d) => d.kind === "audioinput" && d.deviceId)
.map((d) => ({ deviceId: d.deviceId, label: d.label || "Microphone" }));
const videoInputs = devices
.filter((d) => d.kind === "videoinput" && d.deviceId)
.map((d) => ({ deviceId: d.deviceId, label: d.label || "Camera" }));
setAudioDevices(audioInputs);
setVideoDevices(videoInputs);
if (audioInputs.length > 0 && !selectedAudioId) {
setSelectedAudioId(audioInputs[0].deviceId);
}
if (videoInputs.length > 0 && !selectedVideoId) {
setSelectedVideoId(videoInputs[0].deviceId);
}
// Stop the initial permission stream
stream.getTracks().forEach((t) => t.stop());
} catch {
// Permissions denied or no devices
} finally {
setIsLoadingDevices(false);
}
}, [selectedAudioId, selectedVideoId]);
// Enumerate devices on mount
useEffect(() => {
enumerateDevices();
}, [enumerateDevices]);
// Start camera preview
useEffect(() => {
if (!videoEnabled || !selectedVideoId) {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
return;
}
let cancelled = false;
const startPreview = async () => {
try {
// Stop previous stream
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
}
const stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: selectedVideoId } },
});
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch {
// Camera unavailable
}
};
startPreview();
return () => {
cancelled = true;
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
};
}, [videoEnabled, selectedVideoId]);
const handleJoin = () => {
// Stop preview stream before joining
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
onJoin({
audioDeviceId: selectedAudioId,
videoDeviceId: selectedVideoId,
audioEnabled,
videoEnabled,
});
};
return (
<div className={`flex h-full items-center justify-center p-4 ${className ?? ""}`}>
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle>Join Room: {roomName}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Camera preview */}
<div className="relative aspect-video overflow-hidden rounded-lg bg-muted">
{videoEnabled ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
className="h-full w-full object-cover mirror"
style={{ transform: "scaleX(-1)" }}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>Camera off</p>
</div>
)}
</div>
{/* Device selectors */}
{isLoadingDevices ? (
<div className="flex items-center justify-center py-4 text-muted-foreground text-sm">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent mr-2" />
Loading devices...
</div>
) : (
<div className="space-y-4">
{/* Microphone selector */}
<div className="space-y-2">
<label htmlFor={audioSelectId} className="text-sm font-medium">
Microphone
</label>
<div className="flex items-center gap-2">
<Select
value={selectedAudioId}
onValueChange={setSelectedAudioId}
>
<SelectTrigger id={audioSelectId} className="flex-1">
<SelectValue placeholder="Select microphone" />
</SelectTrigger>
<SelectContent>
{audioDevices.map((device) => (
<SelectItem
key={`${audioSelectId}-${device.deviceId}`}
value={device.deviceId}
>
{device.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={audioEnabled ? "secondary" : "destructive"}
size="sm"
onClick={() => setAudioEnabled((prev) => !prev)}
>
{audioEnabled ? "On" : "Off"}
</Button>
</div>
</div>
{/* Camera selector */}
<div className="space-y-2">
<label htmlFor={videoSelectId} className="text-sm font-medium">
Camera
</label>
<div className="flex items-center gap-2">
<Select
value={selectedVideoId}
onValueChange={setSelectedVideoId}
>
<SelectTrigger id={videoSelectId} className="flex-1">
<SelectValue placeholder="Select camera" />
</SelectTrigger>
<SelectContent>
{videoDevices.map((device) => (
<SelectItem
key={`${videoSelectId}-${device.deviceId}`}
value={device.deviceId}
>
{device.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={videoEnabled ? "secondary" : "destructive"}
size="sm"
onClick={() => setVideoEnabled((prev) => !prev)}
>
{videoEnabled ? "On" : "Off"}
</Button>
</div>
</div>
</div>
)}
</CardContent>
<CardFooter>
<Button onClick={handleJoin} className="w-full" size="lg">
Join Room
</Button>
</CardFooter>
</Card>
</div>
);
}
Usage
Basic Video Call Page
// src/app/video/[roomName]/page.tsx
"use client";
import { useState, use } from "react";
import { VideoRoomProvider } from "@/components/video/video-room-provider";
import { ParticipantGrid } from "@/components/video/participant-grid";
import { ControlsBar } from "@/components/video/controls-bar";
import { PrejoinScreen } from "@/components/video/prejoin-screen";
type PageProps = {
params: Promise<{ roomName: string }>;
};
export default function VideoRoomPage({ params }: PageProps) {
const { roomName } = use(params);
const [joined, setJoined] = useState(false);
if (!joined) {
return (
<PrejoinScreen
roomName={roomName}
onJoin={() => setJoined(true)}
/>
);
}
return (
<div className="flex h-screen flex-col">
<VideoRoomProvider roomName={roomName}>
<div className="flex-1 min-h-0">
<ParticipantGrid className="h-full" />
</div>
<ControlsBar onLeave={() => setJoined(false)} />
</VideoRoomProvider>
</div>
);
}
Speaker View Layout
"use client";
import { VideoRoomProvider } from "@/components/video/video-room-provider";
import { SpeakerView } from "@/components/video/speaker-view";
import { ControlsBar } from "@/components/video/controls-bar";
function MeetingRoom({ roomName }: { roomName: string }) {
return (
<div className="flex h-screen flex-col">
<VideoRoomProvider roomName={roomName}>
<div className="flex-1 min-h-0">
<SpeakerView className="h-full" />
</div>
<ControlsBar />
</VideoRoomProvider>
</div>
);
}
Prejoin with Custom Settings
"use client";
import { PrejoinScreen } from "@/components/video/prejoin-screen";
function Lobby({ roomName }: { roomName: string }) {
const handleJoin = (settings: {
audioDeviceId: string | undefined;
videoDeviceId: string | undefined;
audioEnabled: boolean;
videoEnabled: boolean;
}) => {
console.log("Joining with settings:", settings);
// Navigate to room or set joined state
};
return <PrejoinScreen roomName={roomName} onJoin={handleJoin} />;
}
Component Reference
| Component | Purpose | Key Props |
|---|---|---|
VideoRoomProvider |
Wraps children in LiveKitRoom with auto token fetch | roomName, participantName, onConnected, onDisconnected, onError |
ParticipantGrid |
Renders all participants in an adaptive grid | className |
SpeakerView |
Active speaker spotlight with thumbnail strip | className |
ControlsBar |
Media control buttons (mic, camera, screen share, leave) | onLeave, showScreenShare, className |
PrejoinScreen |
Device selection and camera preview before joining | roomName, onJoin, className |
ParticipantTile |
Single participant with video, name overlay, speaking indicator | trackRef, className |
Acceptance Criteria
-
VideoRoomProviderfetches a token from/api/video/tokenand connects to LiveKit -
ParticipantGridrenders all participants in a responsive grid -
SpeakerViewshows the active speaker in a spotlight with others as thumbnails -
ControlsBartoggles microphone, camera, and screen share correctly -
PrejoinScreenenumerates devices and shows a camera preview -
PrejoinScreenallows selecting different audio/video devices -
ParticipantTileshows a speaking indicator when the participant is speaking -
ParticipantTileshows an avatar fallback when video is off - All components use shadcn/ui Button, Card, Select, Avatar
-
@livekit/components-styles/prefabs.cssis imported in the provider - No usage of
anytype anywhere in the code - All components with hooks use
"use client"directive -
useIdis used for generating keys in lists -
tscpasses with no errors -
bun run buildsucceeds
Troubleshooting
"LiveKitRoom" errors about missing context
Symptoms: useLocalParticipant or useTracks throws "must be used within LiveKitRoom".
Cause: Component is not wrapped in VideoRoomProvider or LiveKitRoom.
Fix: Ensure all video UI components are rendered as children of VideoRoomProvider.
Camera preview is mirrored
Symptoms: Text appears backwards in the prejoin camera preview.
Cause: The preview video is CSS-mirrored with transform: scaleX(-1) for a natural selfie view. This is intentional and matches the behavior of Zoom, Google Meet, etc.
Device list is empty
Symptoms: No devices appear in the PrejoinScreen dropdowns.
Cause: Browser permissions for camera/microphone were denied, or the page is not served over HTTPS.
Fix:
- Check browser permissions in the address bar
- For local development,
localhostis treated as secure - For deployment, ensure the site is served over HTTPS
Grid layout looks wrong
Symptoms: Participant tiles are very small or overlap.
Cause: The parent container does not have a defined height.
Fix: Ensure the parent of ParticipantGrid has h-full or an explicit height. The grid uses auto-rows-fr which requires a sized container.
Token fetch fails with 401
Symptoms: VideoRoomProvider shows "Failed to join room" with a 401 error.
Cause: The user is not authenticated.
Fix: Ensure the user is signed in via better-auth before rendering VideoRoomProvider. The /api/video/token route requires authentication.