feat: Livekit bare no recording nor pipeline

This commit is contained in:
Juan
2026-04-01 13:54:26 -05:00
parent b570d202dc
commit 6d84794e36
30 changed files with 1724 additions and 37 deletions

View File

@@ -74,6 +74,7 @@ const recordingTypeOptions: SelectOption[] = [
const platformOptions: SelectOption[] = [
{ label: "Whereby", value: "whereby" },
{ label: "Daily", value: "daily" },
{ label: "LiveKit", value: "livekit" },
];
const roomInitialState = {
@@ -309,10 +310,7 @@ export default function RoomsList() {
return;
}
const platform: "whereby" | "daily" =
room.platform === "whereby" || room.platform === "daily"
? room.platform
: "daily";
const platform = room.platform as "whereby" | "daily" | "livekit";
const roomData = {
name: room.name,
@@ -544,7 +542,10 @@ export default function RoomsList() {
<Select.Root
value={[room.platform]}
onValueChange={(e) => {
const newPlatform = e.value[0] as "whereby" | "daily";
const newPlatform = e.value[0] as
| "whereby"
| "daily"
| "livekit";
const updates: Partial<typeof room> = {
platform: newPlatform,
};

View File

@@ -0,0 +1,212 @@
"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,
} from "@livekit/components-react";
// LiveKit component styles — imported in the global layout to avoid
// Next.js CSS import restrictions in client components.
// See: app/[roomName]/layout.tsx
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=<name>&token=<jwt>
* 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<Meeting | null>(null);
const [connectionError, setConnectionError] = useState(false);
// ── 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");
// ── Join meeting via backend API to get token ─────────────
useEffect(() => {
if (authLastUserId === undefined || !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 },
},
});
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]);
const handleDisconnected = useCallback(() => {
router.push("/browse");
}, [router]);
// ── Loading / error states ────────────────────────────────
if (connectionError) {
return (
<Center h="100vh" bg="gray.50">
<Text fontSize="lg">Failed to connect to meeting</Text>
</Center>
);
}
if (!joinedMeeting) {
return (
<Center h="100vh" bg="gray.50">
<Spinner color="blue.500" size="xl" />
</Center>
);
}
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 (
<Center h="100vh" bg="gray.50">
<Text fontSize="lg">
{!token
? "No access token received from server"
: "No room name received from server"}
</Text>
</Center>
);
}
// ── Render ────────────────────────────────────────────────
// The token already encodes the room name (in VideoGrants.room),
// so LiveKit SDK joins the correct room from the token alone.
return (
<Box w="100vw" h="100vh" bg="black" position="relative">
<LKRoom
serverUrl={serverUrl}
token={token}
connect={true}
audio={true}
video={true}
onDisconnected={handleDisconnected}
data-lk-theme="default"
style={{ height: "100%" }}
>
<VideoConference />
<RoomAudioRenderer />
</LKRoom>
{/* ── Floating overlay buttons (consent, email, extensible) ── */}
{showConsentButton && (
<ConsentDialogButton
meetingId={meetingId}
recordingType={meeting.recording_type}
skipConsent={room.skip_consent}
/>
)}
{showRecordingIndicator && <RecordingIndicator />}
{showEmailFeature && (
<IconButton
aria-label="Email transcript"
position="absolute"
top="56px"
right="8px"
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={showEmailModal}
variant="solid"
borderRadius="full"
>
<LuMail />
</IconButton>
)}
</Box>
);
}

View File

@@ -14,6 +14,7 @@ import MeetingSelection from "../MeetingSelection";
import useRoomDefaultMeeting from "../useRoomDefaultMeeting";
import WherebyRoom from "./WherebyRoom";
import DailyRoom from "./DailyRoom";
import LiveKitRoom from "./LiveKitRoom";
import { useAuth } from "../../lib/AuthProvider";
import { useError } from "../../(errors)/errorContext";
import { parseNonEmptyString } from "../../lib/utils";
@@ -199,8 +200,9 @@ export default function RoomContainer(details: RoomDetails) {
return <DailyRoom meeting={meeting} room={room} />;
case "whereby":
return <WherebyRoom meeting={meeting} room={room} />;
default: {
const _exhaustive: never = platform;
case "livekit":
return <LiveKitRoom meeting={meeting} room={room} />;
default:
return (
<Box
display="flex"
@@ -213,6 +215,5 @@ export default function RoomContainer(details: RoomDetails) {
<Text fontSize="lg">Unknown platform: {platform}</Text>
</Box>
);
}
}
}

View File

@@ -1,4 +1,5 @@
import "./styles/globals.scss";
import "@livekit/components-styles";
import { Metadata, Viewport } from "next";
import { Poppins } from "next/font/google";
import { ErrorProvider } from "./(errors)/errorContext";

View File

@@ -911,6 +911,32 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/livekit/webhook": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Livekit Webhook
* @description Handle LiveKit webhook events.
*
* LiveKit webhook events include:
* - participant_joined / participant_left
* - egress_started / egress_updated / egress_ended
* - room_started / room_finished
* - track_published / track_unpublished
*/
post: operations["v1_livekit_webhook"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/auth/login": {
parameters: {
query?: never;
@@ -1100,7 +1126,7 @@ export interface components {
* Platform
* @enum {string}
*/
platform: "whereby" | "daily";
platform: "whereby" | "daily" | "livekit";
/**
* Skip Consent
* @default false
@@ -1821,7 +1847,7 @@ export interface components {
* Platform
* @enum {string}
*/
platform: "whereby" | "daily";
platform: "whereby" | "daily" | "livekit";
/** Daily Composed Video S3 Key */
daily_composed_video_s3_key?: string | null;
/** Daily Composed Video Duration */
@@ -1921,7 +1947,7 @@ export interface components {
* Platform
* @enum {string}
*/
platform: "whereby" | "daily";
platform: "whereby" | "daily" | "livekit";
/**
* Skip Consent
* @default false
@@ -1979,7 +2005,7 @@ export interface components {
* Platform
* @enum {string}
*/
platform: "whereby" | "daily";
platform: "whereby" | "daily" | "livekit";
/**
* Skip Consent
* @default false
@@ -2358,7 +2384,7 @@ export interface components {
/** Ics Enabled */
ics_enabled?: boolean | null;
/** Platform */
platform?: ("whereby" | "daily") | null;
platform?: ("whereby" | "daily" | "livekit") | null;
/** Skip Consent */
skip_consent?: boolean | null;
/** Email Transcript To */
@@ -4504,6 +4530,26 @@ export interface operations {
};
};
};
v1_livekit_webhook: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
v1_login: {
parameters: {
query?: never;