"use client"; import { useCallback, useEffect, useState } from "react"; import { Box, Spinner, Center, Text, IconButton } from "@chakra-ui/react"; import { useRouter, useParams } from "next/navigation"; import { LiveKitRoom as LKRoom, VideoConference, RoomAudioRenderer, PreJoin, type LocalUserChoices, } from "@livekit/components-react"; import type { components } from "../../reflector-api"; import { useAuth } from "../../lib/AuthProvider"; import { useRoomJoinMeeting } from "../../lib/apiHooks"; import { assertMeetingId } from "../../lib/types"; import { ConsentDialogButton, RecordingIndicator, useConsentDialog, } from "../../lib/consent"; import { useEmailTranscriptDialog } from "../../lib/emailTranscript"; import { featureEnabled } from "../../lib/features"; import { LuMail } from "react-icons/lu"; type Meeting = components["schemas"]["Meeting"]; type Room = components["schemas"]["RoomDetails"]; interface LiveKitRoomProps { meeting: Meeting; room: Room; } /** * Extract LiveKit WebSocket URL, room name, and token from the room_url. * * The backend returns room_url like: ws://host:7880?room=&token= * We split these for the LiveKit React SDK. */ function parseLiveKitUrl(roomUrl: string): { serverUrl: string; roomName: string | null; token: string | null; } { try { const url = new URL(roomUrl); const token = url.searchParams.get("token"); const roomName = url.searchParams.get("room"); url.searchParams.delete("token"); url.searchParams.delete("room"); // Strip trailing slash and leftover ? from URL API const serverUrl = url.toString().replace(/[?/]+$/, ""); return { serverUrl, roomName, token }; } catch { return { serverUrl: roomUrl, roomName: null, token: null }; } } export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) { const router = useRouter(); const params = useParams(); const auth = useAuth(); const authLastUserId = auth.lastUserId; const roomName = params?.roomName as string; const meetingId = assertMeetingId(meeting.id); const joinMutation = useRoomJoinMeeting(); const [joinedMeeting, setJoinedMeeting] = useState(null); const [connectionError, setConnectionError] = useState(false); const [userChoices, setUserChoices] = useState(null); // ── Consent dialog (same hooks as Daily/Whereby) ────────── const { showConsentButton, showRecordingIndicator } = useConsentDialog({ meetingId, recordingType: meeting.recording_type, skipConsent: room.skip_consent, }); // ── Email transcript dialog ─────────────────────────────── const userEmail = auth.status === "authenticated" || auth.status === "refreshing" ? auth.user.email : null; const { showEmailModal } = useEmailTranscriptDialog({ meetingId, userEmail, }); const showEmailFeature = featureEnabled("emailTranscript"); // ── PreJoin defaults (persisted to localStorage for page refresh) ── const STORAGE_KEY = `livekit-username-${roomName}`; const defaultUsername = (() => { if (typeof window !== "undefined") { const saved = localStorage.getItem(STORAGE_KEY); if (saved) return saved; } if (auth.status === "authenticated" || auth.status === "refreshing") { return auth.user.email?.split("@")[0] || auth.user.id?.slice(0, 12) || ""; } return ""; })(); const isJoining = !!userChoices && !joinedMeeting && !connectionError; // ── Join meeting via backend API after PreJoin submit ───── useEffect(() => { if ( authLastUserId === undefined || !userChoices || !meeting?.id || !roomName ) return; let cancelled = false; async function join() { try { const result = await joinMutation.mutateAsync({ params: { path: { room_name: roomName, meeting_id: meeting.id }, query: { display_name: userChoices!.username || undefined }, }, }); if (!cancelled) setJoinedMeeting(result); } catch (err) { console.error("Failed to join LiveKit meeting:", err); if (!cancelled) setConnectionError(true); } } join(); return () => { cancelled = true; }; }, [meeting?.id, roomName, authLastUserId, userChoices]); const handleDisconnected = useCallback(() => { router.push("/browse"); }, [router]); const handlePreJoinSubmit = useCallback( (choices: LocalUserChoices) => { // Persist username for page refresh if (choices.username) { localStorage.setItem(STORAGE_KEY, choices.username); } setUserChoices(choices); }, [STORAGE_KEY], ); // ── PreJoin screen (name + device selection) ────────────── if (!userChoices) { return ( ); } // ── Loading / error states ──────────────────────────────── if (isJoining) { return (
); } if (connectionError) { return (
Failed to connect to meeting
); } if (!joinedMeeting) { return (
); } const { serverUrl, roomName: lkRoomName, token, } = parseLiveKitUrl(joinedMeeting.room_url); if ( serverUrl && !serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://") ) { console.warn( `LiveKit serverUrl has unexpected scheme: ${serverUrl}. Expected ws:// or wss://`, ); } if (!token || !lkRoomName) { return (
{!token ? "No access token received from server" : "No room name received from server"}
); } // ── Render ──────────────────────────────────────────────── // The token already encodes the room name (in VideoGrants.room), // so LiveKit SDK joins the correct room from the token alone. return ( {/* ── Floating overlay buttons (consent, email, extensible) ── */} {showConsentButton && ( )} {showRecordingIndicator && } {showEmailFeature && ( )} ); }