page.tsxā¢7.42 kB
"use client";
import { CloseIcon } from "@/components/CloseIcon";
import { NoAgentNotification } from "@/components/NoAgentNotification";
import TranscriptionView from "@/components/TranscriptionView";
import {
BarVisualizer,
DisconnectButton,
RoomAudioRenderer,
RoomContext,
VideoTrack,
VoiceAssistantControlBar,
useVoiceAssistant,
} from "@livekit/components-react";
import { AnimatePresence, motion } from "framer-motion";
import { Room, RoomEvent } from "livekit-client";
import { useCallback, useEffect, useState } from "react";
import type { ConnectionDetails } from "./api/connection-details/route";
export default function Page() {
const [room] = useState(new Room());
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const onConnectButtonClicked = useCallback(async () => {
// Generate room connection details, including:
// - A random Room name
// - A random Participant name
// - An Access Token to permit the participant to join the room
// - The URL of the LiveKit server to connect to
//
// In real-world application, you would likely allow the user to specify their
// own participant name, and possibly to choose from existing rooms to join.
setError("");
const url = new URL(
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? "/api/connection-details",
window.location.origin
);
url.searchParams.set('password', password);
const response = await fetch(url.toString());
if (!response.ok) {
if (response.status === 401) {
setError("Invalid password");
} else {
setError("Connection failed");
}
return;
}
const connectionDetailsData: ConnectionDetails = await response.json();
await room.connect(connectionDetailsData.serverUrl, connectionDetailsData.participantToken);
await room.localParticipant.setMicrophoneEnabled(true);
}, [room, password]);
useEffect(() => {
room.on(RoomEvent.MediaDevicesError, onDeviceFailure);
return () => {
room.off(RoomEvent.MediaDevicesError, onDeviceFailure);
};
}, [room]);
return (
<main data-lk-theme="default" className="h-full grid content-center bg-[var(--lk-bg)]">
<RoomContext.Provider value={room}>
<div className="lk-room-container max-w-[1024px] w-[90vw] mx-auto max-h-[90vh]">
<SimpleVoiceAssistant
onConnectButtonClicked={onConnectButtonClicked}
password={password}
setPassword={setPassword}
error={error}
/>
</div>
</RoomContext.Provider>
</main>
);
}
function SimpleVoiceAssistant(props: {
onConnectButtonClicked: () => void;
password: string;
setPassword: (password: string) => void;
error: string;
}) {
const { state: agentState } = useVoiceAssistant();
return (
<>
<AnimatePresence mode="wait">
{agentState === "disconnected" ? (
<motion.div
key="disconnected"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, ease: [0.09, 1.04, 0.245, 1.055] }}
className="grid items-center justify-center h-full"
>
<div className="flex flex-col items-center gap-4">
<input
type="password"
placeholder="Enter password"
value={props.password}
onChange={(e) => props.setPassword(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
props.onConnectButtonClicked();
}
}}
className="px-4 py-2 bg-gray-800 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-white"
/>
{props.error && (
<p className="text-red-500 text-sm">{props.error}</p>
)}
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="uppercase px-4 py-2 bg-white text-black rounded-md"
onClick={() => props.onConnectButtonClicked()}
>
Start a conversation
</motion.button>
</div>
</motion.div>
) : (
<motion.div
key="connected"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, ease: [0.09, 1.04, 0.245, 1.055] }}
className="flex flex-col items-center gap-4 h-full"
>
<AgentVisualizer />
<div className="flex-1 w-full">
<TranscriptionView />
</div>
<div className="w-full">
<ControlBar onConnectButtonClicked={props.onConnectButtonClicked} />
</div>
<RoomAudioRenderer />
<NoAgentNotification state={agentState} />
</motion.div>
)}
</AnimatePresence>
</>
);
}
function AgentVisualizer() {
const { state: agentState, videoTrack, audioTrack } = useVoiceAssistant();
if (videoTrack) {
return (
<div className="h-[512px] w-[512px] rounded-lg overflow-hidden">
<VideoTrack trackRef={videoTrack} />
</div>
);
}
return (
<div className="h-[300px] w-full">
<BarVisualizer
state={agentState}
barCount={5}
trackRef={audioTrack}
className="agent-visualizer"
options={{ minHeight: 24 }}
/>
</div>
);
}
function ControlBar(props: { onConnectButtonClicked: () => void }) {
const { state: agentState } = useVoiceAssistant();
return (
<div className="relative h-[60px]">
<AnimatePresence>
{agentState === "disconnected" && (
<motion.button
initial={{ opacity: 0, top: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, top: "-10px" }}
transition={{ duration: 1, ease: [0.09, 1.04, 0.245, 1.055] }}
className="uppercase absolute left-1/2 -translate-x-1/2 px-4 py-2 bg-white text-black rounded-md"
onClick={() => props.onConnectButtonClicked()}
>
Start a conversation
</motion.button>
)}
</AnimatePresence>
<AnimatePresence>
{agentState !== "disconnected" && agentState !== "connecting" && (
<motion.div
initial={{ opacity: 0, top: "10px" }}
animate={{ opacity: 1, top: 0 }}
exit={{ opacity: 0, top: "-10px" }}
transition={{ duration: 0.4, ease: [0.09, 1.04, 0.245, 1.055] }}
className="flex h-8 absolute left-1/2 -translate-x-1/2 justify-center"
>
<VoiceAssistantControlBar controls={{ leave: false }} />
<DisconnectButton>
<CloseIcon />
</DisconnectButton>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function onDeviceFailure(error: Error) {
console.error(error);
alert(
"Error acquiring camera or microphone permissions. Please make sure you grant the necessary permissions in your browser and reload the tab"
);
}