diff --git a/README.md b/README.md index e190a3ee..0ace0ca5 100644 --- a/README.md +++ b/README.md @@ -77,23 +77,34 @@ This starter is designed to be flexible so you can adapt it to your specific age #### Example: App configuration (`app-config.ts`) ```ts -export const APP_CONFIG_DEFAULTS = { +export const APP_CONFIG_DEFAULTS: AppConfig = { companyName: 'LiveKit', pageTitle: 'LiveKit Voice Agent', pageDescription: 'A voice agent built with LiveKit', + supportsChatInput: true, supportsVideoInput: true, supportsScreenShare: true, + isPreConnectBufferEnabled: true, + logo: '/lk-logo.svg', accent: '#002cf2', logoDark: '/lk-logo-dark.svg', accentDark: '#1fd5f9', startButtonText: 'Start call', + + // for LiveKit Cloud Sandbox + sandboxId: undefined, + agentName: undefined, }; ``` You can update these values in [`app-config.ts`](./app-config.ts) to customize branding, features, and UI text for your deployment. +> [!NOTE] +> The `sandboxId` and `agentName` are for the LiveKit Cloud Sandbox environment. +> They are not used for local development. + #### Environment Variables You'll also need to configure your LiveKit credentials in `.env.local` (copy `.env.example` if you don't have one): diff --git a/app-config.ts b/app-config.ts index 87d97dea..cb1a9fd5 100644 --- a/app-config.ts +++ b/app-config.ts @@ -1,4 +1,23 @@ -import type { AppConfig } from './lib/types'; +export interface AppConfig { + pageTitle: string; + pageDescription: string; + companyName: string; + + supportsChatInput: boolean; + supportsVideoInput: boolean; + supportsScreenShare: boolean; + isPreConnectBufferEnabled: boolean; + + logo: string; + startButtonText: string; + accent?: string; + logoDark?: string; + accentDark?: string; + + // for LiveKit Cloud Sandbox + sandboxId?: string; + agentName?: string; +} export const APP_CONFIG_DEFAULTS: AppConfig = { companyName: 'LiveKit', @@ -16,5 +35,7 @@ export const APP_CONFIG_DEFAULTS: AppConfig = { accentDark: '#1fd5f9', startButtonText: 'Start call', + // for LiveKit Cloud Sandbox + sandboxId: undefined, agentName: undefined, }; diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 25847feb..1f56a3ff 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,11 +1,11 @@ import { headers } from 'next/headers'; import { getAppConfig } from '@/lib/utils'; -interface AppLayoutProps { +interface LayoutProps { children: React.ReactNode; } -export default async function AppLayout({ children }: AppLayoutProps) { +export default async function Layout({ children }: LayoutProps) { const hdrs = await headers(); const { companyName, logo, logoDark } = await getAppConfig(hdrs); @@ -39,6 +39,7 @@ export default async function AppLayout({ children }: AppLayoutProps) { + {children} ); diff --git a/app/(app)/opengraph-image.tsx b/app/(app)/opengraph-image.tsx index a438c0b4..9fccff1e 100644 --- a/app/(app)/opengraph-image.tsx +++ b/app/(app)/opengraph-image.tsx @@ -165,7 +165,7 @@ export default async function Image() { gap: 10, }} > - {/* eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text */} + {/* eslint-disable-next-line jsx-a11y/alt-text */} {/* logo */} @@ -179,7 +179,7 @@ export default async function Image() { gap: 10, }} > - {/* eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text */} + {/* eslint-disable-next-line jsx-a11y/alt-text */} {/* title */} diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index eb0e9c56..f4655546 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,5 +1,5 @@ import { headers } from 'next/headers'; -import { App } from '@/components/app'; +import { App } from '@/components/app/app'; import { getAppConfig } from '@/lib/utils'; export default async function Page() { diff --git a/app/api/connection-details/route.ts b/app/api/connection-details/route.ts index 02d6542a..8f2c4841 100644 --- a/app/api/connection-details/route.ts +++ b/app/api/connection-details/route.ts @@ -2,6 +2,13 @@ import { NextResponse } from 'next/server'; import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk'; import { RoomConfiguration } from '@livekit/protocol'; +type ConnectionDetails = { + serverUrl: string; + roomName: string; + participantName: string; + participantToken: string; +}; + // NOTE: you are expected to define the following environment variables in `.env.local`: const API_KEY = process.env.LIVEKIT_API_KEY; const API_SECRET = process.env.LIVEKIT_API_SECRET; @@ -10,13 +17,6 @@ const LIVEKIT_URL = process.env.LIVEKIT_URL; // don't cache the results export const revalidate = 0; -export type ConnectionDetails = { - serverUrl: string; - roomName: string; - participantName: string; - participantToken: string; -}; - export async function POST(req: Request) { try { if (LIVEKIT_URL === undefined) { diff --git a/app/components/Container.tsx b/app/components/Container.tsx deleted file mode 100644 index 8c47b46b..00000000 --- a/app/components/Container.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { cn } from '@/lib/utils'; - -interface ContainerProps { - children: React.ReactNode; - className?: string; -} - -export function Container({ children, className }: ContainerProps) { - return ( -
{children}
- ); -} diff --git a/app/components/Tabs.tsx b/app/components/Tabs.tsx deleted file mode 100644 index 8b7602da..00000000 --- a/app/components/Tabs.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { cn } from '@/lib/utils'; - -export function Tabs() { - const pathname = usePathname(); - - return ( -
- - Base components - - - LiveKit components - -
- ); -} diff --git a/app/components/base/page.tsx b/app/components/base/page.tsx deleted file mode 100644 index 53792483..00000000 --- a/app/components/base/page.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { PlusIcon } from '@phosphor-icons/react/dist/ssr'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Toggle } from '@/components/ui/toggle'; -import { Container } from '../Container'; - -const buttonVariants = ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const; -const toggleVariants = ['default', 'outline'] as const; -const alertVariants = ['default', 'destructive'] as const; - -export default function Base() { - return ( - <> - {/* Button */} - -

A button component.

-
- {buttonVariants.map((variant) => ( -
-

{variant}

-
-
- -
-
- -
-
- -
-
- -
-
-
- ))} -
-
- - {/* Toggle */} - -

A toggle component.

-
- {toggleVariants.map((variant) => ( -
-

{variant}

-
-
- - Size sm - -
-
- - Size default - -
-
- - Size lg - -
-
-
- ))} -
-
- - {/* Alert */} - -

An alert component.

-
- {alertVariants.map((variant) => ( -
-

{variant}

- - Alert {variant} title - This is a {variant} alert description. - -
- ))} -
-
- - {/* Select */} - -

A select component.

-
-
-

Size default

- -
-
-

Size sm

- -
-
-
- - ); -} diff --git a/app/components/layout.tsx b/app/components/layout.tsx deleted file mode 100644 index a9240aef..00000000 --- a/app/components/layout.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { headers } from 'next/headers'; -import { Tabs } from '@/app/components/Tabs'; -import { Provider } from '@/components/provider'; -import { cn, getAppConfig } from '@/lib/utils'; - -export default async function ComponentsLayout({ children }: { children: React.ReactNode }) { - const hdrs = await headers(); - const appConfig = await getAppConfig(hdrs); - return ( -
-
-

Quick Start UI overview

-

- A quick start UI overview for the LiveKit Voice Assistant. -

-
- - -
{children}
-
-
- ); -} diff --git a/app/components/livekit/page.tsx b/app/components/livekit/page.tsx deleted file mode 100644 index 2905fe4d..00000000 --- a/app/components/livekit/page.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Track } from 'livekit-client'; -import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar'; -import { DeviceSelect } from '@/components/livekit/device-select'; -import { TrackToggle } from '@/components/livekit/track-toggle'; -import { Container } from '../Container'; - -export default function LiveKit() { - return ( - <> - {/* Device select */} - -
-

A device select component.

-
-
-
-

Size default

- -
-
-

Size sm

- -
-
-
- - {/* Track toggle */} - -
-

A track toggle component.

-
-
-
-

- Track.Source.Microphone -

- -
-
-

- Track.Source.Camera -

- -
-
-
- - {/* Agent control bar */} - -
-

A control bar component.

-
-
- -
-
- - ); -} diff --git a/app/components/page.tsx b/app/components/page.tsx deleted file mode 100644 index eadebf51..00000000 --- a/app/components/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Components() { - return redirect('/components/base'); -} diff --git a/app/layout.tsx b/app/layout.tsx index f43f71dd..171d4453 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,9 @@ import { Public_Sans } from 'next/font/google'; import localFont from 'next/font/local'; import { headers } from 'next/headers'; -import { APP_CONFIG_DEFAULTS } from '@/app-config'; -import { ApplyThemeScript, ThemeToggle } from '@/components/theme-toggle'; -import { getAppConfig } from '@/lib/utils'; -import './globals.css'; +import { ApplyThemeScript, ThemeToggle } from '@/components/app/theme-toggle'; +import { cn, getAppConfig, getStyles } from '@/lib/utils'; +import '@/styles/globals.css'; const publicSans = Public_Sans({ variable: '--font-public-sans', @@ -12,29 +11,30 @@ const publicSans = Public_Sans({ }); const commitMono = localFont({ + display: 'swap', + variable: '--font-commit-mono', src: [ { - path: './fonts/CommitMono-400-Regular.otf', + path: '../fonts/CommitMono-400-Regular.otf', weight: '400', style: 'normal', }, { - path: './fonts/CommitMono-700-Regular.otf', + path: '../fonts/CommitMono-700-Regular.otf', weight: '700', style: 'normal', }, { - path: './fonts/CommitMono-400-Italic.otf', + path: '../fonts/CommitMono-400-Italic.otf', weight: '400', style: 'italic', }, { - path: './fonts/CommitMono-700-Italic.otf', + path: '../fonts/CommitMono-700-Italic.otf', weight: '700', style: 'italic', }, ], - variable: '--font-commit-mono', }); interface RootLayoutProps { @@ -43,32 +43,27 @@ interface RootLayoutProps { export default async function RootLayout({ children }: RootLayoutProps) { const hdrs = await headers(); - const { accent, accentDark, pageTitle, pageDescription } = await getAppConfig(hdrs); - - // check provided accent colors against defaults, and apply styles if they differ (or in development mode) - // generate a hover color for the accent color by mixing it with 20% black - const styles = [ - process.env.NODE_ENV === 'development' || accent !== APP_CONFIG_DEFAULTS.accent - ? `:root { --primary: ${accent}; --primary-hover: color-mix(in srgb, ${accent} 80%, #000); }` - : '', - process.env.NODE_ENV === 'development' || accentDark !== APP_CONFIG_DEFAULTS.accentDark - ? `.dark { --primary: ${accentDark}; --primary-hover: color-mix(in srgb, ${accentDark} 80%, #000); }` - : '', - ] - .filter(Boolean) - .join('\n'); + const appConfig = await getAppConfig(hdrs); + const { pageTitle, pageDescription } = appConfig; + const styles = getStyles(appConfig); return ( - + {styles && } {pageTitle} - + {children}
diff --git a/app/ui/layout.tsx b/app/ui/layout.tsx new file mode 100644 index 00000000..c7202785 --- /dev/null +++ b/app/ui/layout.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { headers } from 'next/headers'; +import { SessionProvider } from '@/components/app/session-provider'; +import { getAppConfig } from '@/lib/utils'; + +export default async function ComponentsLayout({ children }: { children: React.ReactNode }) { + const hdrs = await headers(); + const appConfig = await getAppConfig(hdrs); + + return ( + +
+
+
+

LiveKit UI

+

+ A set of UI components for building LiveKit-powered voice experiences. +

+

+ Built with{' '} + + Shadcn + + ,{' '} + + Motion + + , and{' '} + + LiveKit + + . +

+

Open Source.

+
+ +
{children}
+
+
+
+ ); +} diff --git a/app/ui/page.tsx b/app/ui/page.tsx new file mode 100644 index 00000000..83e1a7ba --- /dev/null +++ b/app/ui/page.tsx @@ -0,0 +1,268 @@ +import { type VariantProps } from 'class-variance-authority'; +import { Track } from 'livekit-client'; +import { MicrophoneIcon } from '@phosphor-icons/react/dist/ssr'; +import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar'; +import { TrackDeviceSelect } from '@/components/livekit/agent-control-bar/track-device-select'; +import { TrackSelector } from '@/components/livekit/agent-control-bar/track-selector'; +import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle'; +import { Alert, AlertDescription, AlertTitle, alertVariants } from '@/components/livekit/alert'; +import { AlertToast } from '@/components/livekit/alert-toast'; +import { Button, buttonVariants } from '@/components/livekit/button'; +import { ChatEntry } from '@/components/livekit/chat-entry'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/livekit/select'; +import { ShimmerText } from '@/components/livekit/shimmer-text'; +import { Toggle, toggleVariants } from '@/components/livekit/toggle'; +import { cn } from '@/lib/utils'; + +type toggleVariantsType = VariantProps['variant']; +type toggleVariantsSizeType = VariantProps['size']; +type buttonVariantsType = VariantProps['variant']; +type buttonVariantsSizeType = VariantProps['size']; +type alertVariantsType = VariantProps['variant']; + +interface ContainerProps { + componentName?: string; + children: React.ReactNode; + className?: string; +} + +function Container({ componentName, children, className }: ContainerProps) { + return ( +
+

+ {componentName} +

+
+ {children} +
+
+ ); +} + +function StoryTitle({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +export default function Base() { + return ( + <> +

Primitives

+ + {/* Button */} + + + + + + + + + + + + + {['default', 'primary', 'secondary', 'outline', 'ghost', 'link', 'destructive'].map( + (variant) => ( + + + {['sm', 'default', 'lg', 'icon'].map((size) => ( + + ))} + + ) + )} + +
SmallDefaultLargeIcon
{variant} + +
+
+ + {/* Toggle */} + + + + + + + + + + + + + {['default', 'primary', 'secondary', 'outline'].map((variant) => ( + + + {['sm', 'default', 'lg', 'icon'].map((size) => ( + + ))} + + ))} + +
SmallDefaultLargeIcon
{variant} + + {size === 'icon' ? : 'Toggle'} + +
+
+ + {/* Alert */} + + {['default', 'destructive'].map((variant) => ( +
+ {variant} + + Alert {variant} title + This is a {variant} alert description. + +
+ ))} +
+ + {/* Select */} + +
+
+ Size default + +
+
+ Size sm + +
+
+
+ +

Components

+ + {/* Agent control bar */} + +
+ +
+
+ + {/* Track device select */} + +
+
+ Size default + +
+
+ Size sm + +
+
+
+ + {/* Track toggle */} + +
+
+ Track.Source.Microphone + +
+
+ Track.Source.Camera + +
+
+
+ + {/* Track selector */} + +
+
+ Track.Source.Camera + +
+
+ Track.Source.Microphone + +
+
+
+ + {/* Chat entry */} + +
+ + +
+
+ + {/* Shimmer text */} + +
+ This is shimmer text +
+
+ + {/* Alert toast */} + + Alert toast +
+ +
+
+ + ); +} diff --git a/components/app.tsx b/components/app.tsx deleted file mode 100644 index 5da7c44b..00000000 --- a/components/app.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import { Room, RoomEvent } from 'livekit-client'; -import { motion } from 'motion/react'; -import { RoomAudioRenderer, RoomContext, StartAudio } from '@livekit/components-react'; -import { toastAlert } from '@/components/alert-toast'; -import { SessionView } from '@/components/session-view'; -import { Toaster } from '@/components/ui/sonner'; -import { Welcome } from '@/components/welcome'; -import useConnectionDetails from '@/hooks/useConnectionDetails'; -import type { AppConfig } from '@/lib/types'; - -const MotionWelcome = motion.create(Welcome); -const MotionSessionView = motion.create(SessionView); - -interface AppProps { - appConfig: AppConfig; -} - -export function App({ appConfig }: AppProps) { - const room = useMemo(() => new Room(), []); - const [sessionStarted, setSessionStarted] = useState(false); - const { refreshConnectionDetails, existingOrRefreshConnectionDetails } = - useConnectionDetails(appConfig); - - useEffect(() => { - const onDisconnected = () => { - setSessionStarted(false); - refreshConnectionDetails(); - }; - const onMediaDevicesError = (error: Error) => { - toastAlert({ - title: 'Encountered an error with your media devices', - description: `${error.name}: ${error.message}`, - }); - }; - room.on(RoomEvent.MediaDevicesError, onMediaDevicesError); - room.on(RoomEvent.Disconnected, onDisconnected); - return () => { - room.off(RoomEvent.Disconnected, onDisconnected); - room.off(RoomEvent.MediaDevicesError, onMediaDevicesError); - }; - }, [room, refreshConnectionDetails]); - - useEffect(() => { - let aborted = false; - if (sessionStarted && room.state === 'disconnected') { - Promise.all([ - room.localParticipant.setMicrophoneEnabled(true, undefined, { - preConnectBuffer: appConfig.isPreConnectBufferEnabled, - }), - existingOrRefreshConnectionDetails().then((connectionDetails) => - room.connect(connectionDetails.serverUrl, connectionDetails.participantToken) - ), - ]).catch((error) => { - if (aborted) { - // Once the effect has cleaned up after itself, drop any errors - // - // These errors are likely caused by this effect rerunning rapidly, - // resulting in a previous run `disconnect` running in parallel with - // a current run `connect` - return; - } - - toastAlert({ - title: 'There was an error connecting to the agent', - description: `${error.name}: ${error.message}`, - }); - }); - } - return () => { - aborted = true; - room.disconnect(); - }; - }, [room, sessionStarted, appConfig.isPreConnectBufferEnabled]); - - const { startButtonText } = appConfig; - - return ( -
- setSessionStarted(true)} - disabled={sessionStarted} - initial={{ opacity: 1 }} - animate={{ opacity: sessionStarted ? 0 : 1 }} - transition={{ duration: 0.5, ease: 'linear', delay: sessionStarted ? 0 : 0.5 }} - /> - - - - - {/* --- */} - - - - -
- ); -} diff --git a/components/app/app.tsx b/components/app/app.tsx new file mode 100644 index 00000000..b390e062 --- /dev/null +++ b/components/app/app.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { RoomAudioRenderer, StartAudio } from '@livekit/components-react'; +import type { AppConfig } from '@/app-config'; +import { SessionProvider } from '@/components/app/session-provider'; +import { ViewController } from '@/components/app/view-controller'; +import { Toaster } from '@/components/livekit/toaster'; + +interface AppProps { + appConfig: AppConfig; +} + +export function App({ appConfig }: AppProps) { + return ( + +
+ +
+ + + +
+ ); +} diff --git a/components/app/chat-transcript.tsx b/components/app/chat-transcript.tsx new file mode 100644 index 00000000..b67d0a67 --- /dev/null +++ b/components/app/chat-transcript.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react'; +import { type ReceivedChatMessage } from '@livekit/components-react'; +import { ChatEntry } from '@/components/livekit/chat-entry'; + +const MotionContainer = motion.create('div'); +const MotionChatEntry = motion.create(ChatEntry); + +const CONTAINER_MOTION_PROPS = { + variants: { + hidden: { + opacity: 0, + transition: { + ease: 'easeOut', + duration: 0.3, + staggerChildren: 0.1, + staggerDirection: -1, + }, + }, + visible: { + opacity: 1, + transition: { + delay: 0.2, + ease: 'easeOut', + duration: 0.3, + stagerDelay: 0.2, + staggerChildren: 0.1, + staggerDirection: 1, + }, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', +}; + +const MESSAGE_MOTION_PROPS = { + variants: { + hidden: { + opacity: 0, + translateY: 10, + }, + visible: { + opacity: 1, + translateY: 0, + }, + }, +}; + +interface ChatTranscriptProps { + hidden?: boolean; + messages?: ReceivedChatMessage[]; +} + +export function ChatTranscript({ + hidden = false, + messages = [], + ...props +}: ChatTranscriptProps & Omit, 'ref'>) { + return ( + + {!hidden && ( + + {messages.map(({ id, timestamp, from, message, editTimestamp }: ReceivedChatMessage) => { + const locale = navigator?.language ?? 'en-US'; + const messageOrigin = from?.isLocal ? 'local' : 'remote'; + const hasBeenEdited = !!editTimestamp; + + return ( + + ); + })} + + )} + + ); +} diff --git a/components/app/preconnect-message.tsx b/components/app/preconnect-message.tsx new file mode 100644 index 00000000..719c3813 --- /dev/null +++ b/components/app/preconnect-message.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { AnimatePresence, motion } from 'motion/react'; +import { type ReceivedChatMessage } from '@livekit/components-react'; +import { ShimmerText } from '@/components/livekit/shimmer-text'; +import { cn } from '@/lib/utils'; + +const MotionMessage = motion.create('p'); + +const VIEW_MOTION_PROPS = { + variants: { + visible: { + opacity: 1, + transition: { + ease: 'easeIn', + duration: 0.5, + delay: 0.8, + }, + }, + hidden: { + opacity: 0, + transition: { + ease: 'easeIn', + duration: 0.5, + delay: 0, + }, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', +}; + +interface PreConnectMessageProps { + messages?: ReceivedChatMessage[]; + className?: string; +} + +export function PreConnectMessage({ className, messages = [] }: PreConnectMessageProps) { + return ( + + {messages.length === 0 && ( + 0} + className={cn('pointer-events-none text-center', className)} + > + + Agent is listening, ask it a question + + + )} + + ); +} diff --git a/components/app/session-provider.tsx b/components/app/session-provider.tsx new file mode 100644 index 00000000..1906f4ca --- /dev/null +++ b/components/app/session-provider.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { createContext, useContext, useMemo } from 'react'; +import { RoomContext } from '@livekit/components-react'; +import { APP_CONFIG_DEFAULTS, type AppConfig } from '@/app-config'; +import { useRoom } from '@/hooks/useRoom'; + +const SessionContext = createContext<{ + appConfig: AppConfig; + isSessionActive: boolean; + startSession: () => void; + endSession: () => void; +}>({ + appConfig: APP_CONFIG_DEFAULTS, + isSessionActive: false, + startSession: () => {}, + endSession: () => {}, +}); + +interface SessionProviderProps { + appConfig: AppConfig; + children: React.ReactNode; +} + +export const SessionProvider = ({ appConfig, children }: SessionProviderProps) => { + const { room, isSessionActive, startSession, endSession } = useRoom(appConfig); + const contextValue = useMemo( + () => ({ appConfig, isSessionActive, startSession, endSession }), + [appConfig, isSessionActive, startSession, endSession] + ); + + return ( + + {children} + + ); +}; + +export function useSession() { + return useContext(SessionContext); +} diff --git a/components/app/session-view.tsx b/components/app/session-view.tsx new file mode 100644 index 00000000..10f6e857 --- /dev/null +++ b/components/app/session-view.tsx @@ -0,0 +1,120 @@ +'use client'; + +import React, { useState } from 'react'; +import { motion } from 'motion/react'; +import type { AppConfig } from '@/app-config'; +import { ChatTranscript } from '@/components/app/chat-transcript'; +import { PreConnectMessage } from '@/components/app/preconnect-message'; +import { TileLayout } from '@/components/app/tile-layout'; +import { + AgentControlBar, + type ControlBarControls, +} from '@/components/livekit/agent-control-bar/agent-control-bar'; +import { useChatMessages } from '@/hooks/useChatMessages'; +import { useConnectionTimeout } from '@/hooks/useConnectionTimout'; +import { useDebugMode } from '@/hooks/useDebug'; +import { cn } from '@/lib/utils'; +import { ScrollArea } from '../livekit/scroll-area/scroll-area'; + +const MotionBottom = motion.create('div'); + +const IN_DEVELOPMENT = process.env.NODE_ENV !== 'production'; +const BOTTOM_VIEW_MOTION_PROPS = { + variants: { + visible: { + opacity: 1, + translateY: '0%', + }, + hidden: { + opacity: 0, + translateY: '100%', + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', + transition: { + duration: 0.3, + delay: 0.5, + ease: 'easeOut', + }, +}; + +interface FadeProps { + top?: boolean; + bottom?: boolean; + className?: string; +} + +export function Fade({ top = false, bottom = false, className }: FadeProps) { + return ( +
+ ); +} +interface SessionViewProps { + appConfig: AppConfig; +} + +export const SessionView = ({ + appConfig, + ...props +}: React.ComponentProps<'section'> & SessionViewProps) => { + useConnectionTimeout(200_000); + useDebugMode({ enabled: IN_DEVELOPMENT }); + + const messages = useChatMessages(); + const [chatOpen, setChatOpen] = useState(false); + + const controls: ControlBarControls = { + leave: true, + microphone: true, + chat: appConfig.supportsChatInput, + camera: appConfig.supportsVideoInput, + screenShare: appConfig.supportsVideoInput, + }; + + return ( +
+ {/* Chat Transcript */} +
+ + + +
+ + {/* Tile Layout */} + + + {/* Bottom */} + + {appConfig.isPreConnectBufferEnabled && ( + + )} +
+ + +
+
+
+ ); +}; diff --git a/components/theme-toggle.tsx b/components/app/theme-toggle.tsx similarity index 98% rename from components/theme-toggle.tsx rename to components/app/theme-toggle.tsx index 33bd83ff..ffefc0da 100644 --- a/components/theme-toggle.tsx +++ b/components/app/theme-toggle.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { MonitorIcon, MoonIcon, SunIcon } from '@phosphor-icons/react'; -import type { ThemeMode } from '@/lib/types'; import { THEME_MEDIA_QUERY, THEME_STORAGE_KEY, cn } from '@/lib/utils'; const THEME_SCRIPT = ` @@ -23,6 +22,8 @@ const THEME_SCRIPT = ` .replace(/\n/g, '') .replace(/\s+/g, ' '); +export type ThemeMode = 'dark' | 'light' | 'system'; + function applyTheme(theme: ThemeMode) { const doc = document.documentElement; diff --git a/components/app/tile-layout.tsx b/components/app/tile-layout.tsx new file mode 100644 index 00000000..33372276 --- /dev/null +++ b/components/app/tile-layout.tsx @@ -0,0 +1,238 @@ +import React, { useMemo } from 'react'; +import { Track } from 'livekit-client'; +import { AnimatePresence, motion } from 'motion/react'; +import { + BarVisualizer, + type TrackReference, + VideoTrack, + useLocalParticipant, + useTracks, + useVoiceAssistant, +} from '@livekit/components-react'; +import { cn } from '@/lib/utils'; + +const MotionContainer = motion.create('div'); + +const ANIMATION_TRANSITION = { + type: 'spring', + stiffness: 675, + damping: 75, + mass: 1, +}; + +const classNames = { + // GRID + // 2 Columns x 3 Rows + grid: [ + 'h-full w-full', + 'grid gap-x-2 place-content-center', + 'grid-cols-[1fr_1fr] grid-rows-[90px_1fr_90px]', + ], + // Agent + // chatOpen: true, + // hasSecondTile: true + // layout: Column 1 / Row 1 + // align: x-end y-center + agentChatOpenWithSecondTile: ['col-start-1 row-start-1', 'self-center justify-self-end'], + // Agent + // chatOpen: true, + // hasSecondTile: false + // layout: Column 1 / Row 1 / Column-Span 2 + // align: x-center y-center + agentChatOpenWithoutSecondTile: ['col-start-1 row-start-1', 'col-span-2', 'place-content-center'], + // Agent + // chatOpen: false + // layout: Column 1 / Row 1 / Column-Span 2 / Row-Span 3 + // align: x-center y-center + agentChatClosed: ['col-start-1 row-start-1', 'col-span-2 row-span-3', 'place-content-center'], + // Second tile + // chatOpen: true, + // hasSecondTile: true + // layout: Column 2 / Row 1 + // align: x-start y-center + secondTileChatOpen: ['col-start-2 row-start-1', 'self-center justify-self-start'], + // Second tile + // chatOpen: false, + // hasSecondTile: false + // layout: Column 2 / Row 2 + // align: x-end y-end + secondTileChatClosed: ['col-start-2 row-start-3', 'place-content-end'], +}; + +export function useLocalTrackRef(source: Track.Source) { + const { localParticipant } = useLocalParticipant(); + const publication = localParticipant.getTrackPublication(source); + const trackRef = useMemo( + () => (publication ? { source, participant: localParticipant, publication } : undefined), + [source, publication, localParticipant] + ); + return trackRef; +} + +interface TileLayoutProps { + chatOpen: boolean; +} + +export function TileLayout({ chatOpen }: TileLayoutProps) { + const { + state: agentState, + audioTrack: agentAudioTrack, + videoTrack: agentVideoTrack, + } = useVoiceAssistant(); + const [screenShareTrack] = useTracks([Track.Source.ScreenShare]); + const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera); + + const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted; + const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted; + const hasSecondTile = isCameraEnabled || isScreenShareEnabled; + + const animationDelay = chatOpen ? 0 : 0.15; + const isAvatar = agentVideoTrack !== undefined; + const videoWidth = agentVideoTrack?.publication.dimensions?.width ?? 0; + const videoHeight = agentVideoTrack?.publication.dimensions?.height ?? 0; + + return ( +
+
+
+ {/* Agent */} +
+ + {!isAvatar && ( + // Audio Agent + + + + + + )} + + {isAvatar && ( + // Avatar Agent + + + + )} + +
+ +
+ {/* Camera & Screen Share */} + + {((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && ( + + + + )} + +
+
+
+
+ ); +} diff --git a/components/app/view-controller.tsx b/components/app/view-controller.tsx new file mode 100644 index 00000000..4519c44f --- /dev/null +++ b/components/app/view-controller.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useRef } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { useRoomContext } from '@livekit/components-react'; +import { useSession } from '@/components/app/session-provider'; +import { SessionView } from '@/components/app/session-view'; +import { WelcomeView } from '@/components/app/welcome-view'; + +const MotionWelcomeView = motion.create(WelcomeView); +const MotionSessionView = motion.create(SessionView); + +const VIEW_MOTION_PROPS = { + variants: { + visible: { + opacity: 1, + }, + hidden: { + opacity: 0, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', + transition: { + duration: 0.5, + ease: 'linear', + }, +}; + +export function ViewController() { + const room = useRoomContext(); + const isSessionActiveRef = useRef(false); + const { appConfig, isSessionActive, startSession } = useSession(); + + // animation handler holds a reference to stale isSessionActive value + isSessionActiveRef.current = isSessionActive; + + // disconnect room after animation completes + const handleAnimationComplete = () => { + if (!isSessionActiveRef.current && room.state !== 'disconnected') { + room.disconnect(); + } + }; + + return ( + + {/* Welcome screen */} + {!isSessionActive && ( + + )} + {/* Session view */} + {isSessionActive && ( + + )} + + ); +} diff --git a/components/app/welcome-view.tsx b/components/app/welcome-view.tsx new file mode 100644 index 00000000..6356d60d --- /dev/null +++ b/components/app/welcome-view.tsx @@ -0,0 +1,61 @@ +import { Button } from '@/components/livekit/button'; + +function WelcomeImage() { + return ( + + + + ); +} + +interface WelcomeViewProps { + startButtonText: string; + onStartCall: () => void; +} + +export const WelcomeView = ({ + startButtonText, + onStartCall, + ref, +}: React.ComponentProps<'div'> & WelcomeViewProps) => { + return ( +
+
+ + +

+ Chat live with your voice AI agent +

+ + +
+ +
+

+ Need help getting set up? Check out the{' '} + + Voice AI quickstart + + . +

+
+
+ ); +}; diff --git a/components/livekit/agent-control-bar/agent-control-bar.tsx b/components/livekit/agent-control-bar/agent-control-bar.tsx index 6790feef..1b53b1c4 100644 --- a/components/livekit/agent-control-bar/agent-control-bar.tsx +++ b/components/livekit/agent-control-bar/agent-control-bar.tsx @@ -1,26 +1,31 @@ 'use client'; -import * as React from 'react'; -import { useCallback } from 'react'; +import { type HTMLAttributes, useCallback, useState } from 'react'; import { Track } from 'livekit-client'; -import { BarVisualizer, useRemoteParticipants } from '@livekit/components-react'; +import { useChat, useRemoteParticipants } from '@livekit/components-react'; import { ChatTextIcon, PhoneDisconnectIcon } from '@phosphor-icons/react/dist/ssr'; -import { ChatInput } from '@/components/livekit/chat/chat-input'; -import { Button } from '@/components/ui/button'; -import { Toggle } from '@/components/ui/toggle'; -import { AppConfig } from '@/lib/types'; +import { useSession } from '@/components/app/session-provider'; +import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle'; +import { Button } from '@/components/livekit/button'; +import { Toggle } from '@/components/livekit/toggle'; import { cn } from '@/lib/utils'; -import { DeviceSelect } from '../device-select'; -import { TrackToggle } from '../track-toggle'; -import { UseAgentControlBarProps, useAgentControlBar } from './hooks/use-agent-control-bar'; - -export interface AgentControlBarProps - extends React.HTMLAttributes, - UseAgentControlBarProps { - capabilities: Pick; - onChatOpenChange?: (open: boolean) => void; - onSendMessage?: (message: string) => Promise; +import { ChatInput } from './chat-input'; +import { UseInputControlsProps, useInputControls } from './hooks/use-input-controls'; +import { usePublishPermissions } from './hooks/use-publish-permissions'; +import { TrackSelector } from './track-selector'; + +export interface ControlBarControls { + leave?: boolean; + camera?: boolean; + microphone?: boolean; + screenShare?: boolean; + chat?: boolean; +} + +export interface AgentControlBarProps extends UseInputControlsProps { + controls?: ControlBarControls; onDisconnect?: () => void; + onChatOpenChange?: (open: boolean) => void; onDeviceError?: (error: { source: Track.Source; error: Error }) => void; } @@ -30,199 +35,137 @@ export interface AgentControlBarProps export function AgentControlBar({ controls, saveUserChoices = true, - capabilities, className, - onSendMessage, - onChatOpenChange, onDisconnect, onDeviceError, + onChatOpenChange, ...props -}: AgentControlBarProps) { +}: AgentControlBarProps & HTMLAttributes) { + const { send } = useChat(); const participants = useRemoteParticipants(); - const [chatOpen, setChatOpen] = React.useState(false); - const [isSendingMessage, setIsSendingMessage] = React.useState(false); - - const isAgentAvailable = participants.some((p) => p.isAgent); - const isInputDisabled = !chatOpen || !isAgentAvailable || isSendingMessage; - - const [isDisconnecting, setIsDisconnecting] = React.useState(false); + const [chatOpen, setChatOpen] = useState(false); + const publishPermissions = usePublishPermissions(); + const { isSessionActive, endSession } = useSession(); const { micTrackRef, - visibleControls, cameraToggle, microphoneToggle, screenShareToggle, handleAudioDeviceChange, handleVideoDeviceChange, - handleDisconnect, - } = useAgentControlBar({ - controls, - saveUserChoices, - }); + handleMicrophoneDeviceSelectError, + handleCameraDeviceSelectError, + } = useInputControls({ onDeviceError, saveUserChoices }); const handleSendMessage = async (message: string) => { - setIsSendingMessage(true); - try { - await onSendMessage?.(message); - } finally { - setIsSendingMessage(false); - } + await send(message); }; - const onLeave = async () => { - setIsDisconnecting(true); - await handleDisconnect(); - setIsDisconnecting(false); + const handleToggleTranscript = useCallback( + (open: boolean) => { + setChatOpen(open); + onChatOpenChange?.(open); + }, + [onChatOpenChange, setChatOpen] + ); + + const handleDisconnect = useCallback(async () => { + endSession(); onDisconnect?.(); - }; + }, [endSession, onDisconnect]); - React.useEffect(() => { - onChatOpenChange?.(chatOpen); - }, [chatOpen, onChatOpenChange]); + const visibleControls = { + leave: controls?.leave ?? true, + microphone: controls?.microphone ?? publishPermissions.microphone, + screenShare: controls?.screenShare ?? publishPermissions.screenShare, + camera: controls?.camera ?? publishPermissions.camera, + chat: controls?.chat ?? publishPermissions.data, + }; - const onMicrophoneDeviceSelectError = useCallback( - (error: Error) => { - onDeviceError?.({ source: Track.Source.Microphone, error }); - }, - [onDeviceError] - ); - const onCameraDeviceSelectError = useCallback( - (error: Error) => { - onDeviceError?.({ source: Track.Source.Camera, error }); - }, - [onDeviceError] - ); + const isAgentAvailable = participants.some((p) => p.isAgent); return (
- {capabilities.supportsChatInput && ( -
-
- -
-
-
+ {/* Chat Input */} + {visibleControls.chat && ( + )} -
-
+
+
+ {/* Toggle Microphone */} {visibleControls.microphone && ( -
- - - - - -
- -
- )} - - {capabilities.supportsVideoInput && visibleControls.camera && ( -
- -
- -
+ )} - {capabilities.supportsScreenShare && visibleControls.screenShare && ( -
- -
+ {/* Toggle Camera */} + {visibleControls.camera && ( + )} - {visibleControls.chat && ( - - - + aria-label="Toggle screen share" + source={Track.Source.ScreenShare} + pressed={screenShareToggle.enabled} + disabled={screenShareToggle.pending} + onPressedChange={screenShareToggle.toggle} + /> )} + + {/* Toggle Transcript */} + + +
+ + {/* Disconnect */} {visibleControls.leave && ( + + + ); +} diff --git a/components/livekit/agent-control-bar/hooks/use-agent-control-bar.ts b/components/livekit/agent-control-bar/hooks/use-input-controls.ts similarity index 56% rename from components/livekit/agent-control-bar/hooks/use-agent-control-bar.ts rename to components/livekit/agent-control-bar/hooks/use-input-controls.ts index 2c1495e8..e89f173b 100644 --- a/components/livekit/agent-control-bar/hooks/use-agent-control-bar.ts +++ b/components/livekit/agent-control-bar/hooks/use-input-controls.ts @@ -1,63 +1,51 @@ -import * as React from 'react'; +import { useCallback, useMemo } from 'react'; import { Track } from 'livekit-client'; import { type TrackReferenceOrPlaceholder, useLocalParticipant, usePersistentUserChoices, - useRoomContext, useTrackToggle, } from '@livekit/components-react'; -import { usePublishPermissions } from './use-publish-permissions'; -export interface ControlBarControls { - microphone?: boolean; - screenShare?: boolean; - chat?: boolean; - camera?: boolean; - leave?: boolean; -} - -export interface UseAgentControlBarProps { - controls?: ControlBarControls; +export interface UseInputControlsProps { saveUserChoices?: boolean; + onDisconnect?: () => void; onDeviceError?: (error: { source: Track.Source; error: Error }) => void; } -export interface UseAgentControlBarReturn { +export interface UseInputControlsReturn { micTrackRef: TrackReferenceOrPlaceholder; - visibleControls: ControlBarControls; microphoneToggle: ReturnType>; cameraToggle: ReturnType>; screenShareToggle: ReturnType>; - handleDisconnect: () => void; handleAudioDeviceChange: (deviceId: string) => void; handleVideoDeviceChange: (deviceId: string) => void; + handleMicrophoneDeviceSelectError: (error: Error) => void; + handleCameraDeviceSelectError: (error: Error) => void; } -export function useAgentControlBar(props: UseAgentControlBarProps = {}): UseAgentControlBarReturn { - const { controls, saveUserChoices = true } = props; - const visibleControls = { - leave: true, - ...controls, - }; +export function useInputControls({ + saveUserChoices = true, + onDeviceError, +}: UseInputControlsProps = {}): UseInputControlsReturn { const { microphoneTrack, localParticipant } = useLocalParticipant(); - const publishPermissions = usePublishPermissions(); - const room = useRoomContext(); const microphoneToggle = useTrackToggle({ source: Track.Source.Microphone, - onDeviceError: (error) => props.onDeviceError?.({ source: Track.Source.Microphone, error }), + onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Microphone, error }), }); + const cameraToggle = useTrackToggle({ source: Track.Source.Camera, - onDeviceError: (error) => props.onDeviceError?.({ source: Track.Source.Camera, error }), + onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Camera, error }), }); + const screenShareToggle = useTrackToggle({ source: Track.Source.ScreenShare, - onDeviceError: (error) => props.onDeviceError?.({ source: Track.Source.ScreenShare, error }), + onDeviceError: (error) => onDeviceError?.({ source: Track.Source.ScreenShare, error }), }); - const micTrackRef = React.useMemo(() => { + const micTrackRef = useMemo(() => { return { participant: localParticipant, source: Track.Source.Microphone, @@ -65,41 +53,28 @@ export function useAgentControlBar(props: UseAgentControlBarProps = {}): UseAgen }; }, [localParticipant, microphoneTrack]); - visibleControls.microphone ??= publishPermissions.microphone; - visibleControls.screenShare ??= publishPermissions.screenShare; - visibleControls.camera ??= publishPermissions.camera; - visibleControls.chat ??= publishPermissions.data; - const { saveAudioInputEnabled, - saveAudioInputDeviceId, saveVideoInputEnabled, + saveAudioInputDeviceId, saveVideoInputDeviceId, - } = usePersistentUserChoices({ - preventSave: !saveUserChoices, - }); - - const handleDisconnect = React.useCallback(async () => { - if (room) { - await room.disconnect(); - } - }, [room]); + } = usePersistentUserChoices({ preventSave: !saveUserChoices }); - const handleAudioDeviceChange = React.useCallback( + const handleAudioDeviceChange = useCallback( (deviceId: string) => { saveAudioInputDeviceId(deviceId ?? 'default'); }, [saveAudioInputDeviceId] ); - const handleVideoDeviceChange = React.useCallback( + const handleVideoDeviceChange = useCallback( (deviceId: string) => { saveVideoInputDeviceId(deviceId ?? 'default'); }, [saveVideoInputDeviceId] ); - const handleToggleCamera = React.useCallback( + const handleToggleCamera = useCallback( async (enabled?: boolean) => { if (screenShareToggle.enabled) { screenShareToggle.toggle(false); @@ -108,31 +83,39 @@ export function useAgentControlBar(props: UseAgentControlBarProps = {}): UseAgen // persist video input enabled preference saveVideoInputEnabled(!cameraToggle.enabled); }, - [cameraToggle.enabled, screenShareToggle.enabled] + [cameraToggle, screenShareToggle, saveVideoInputEnabled] ); - const handleToggleMicrophone = React.useCallback( + const handleToggleMicrophone = useCallback( async (enabled?: boolean) => { await microphoneToggle.toggle(enabled); // persist audio input enabled preference saveAudioInputEnabled(!microphoneToggle.enabled); }, - [microphoneToggle.enabled] + [microphoneToggle, saveAudioInputEnabled] ); - const handleToggleScreenShare = React.useCallback( + const handleToggleScreenShare = useCallback( async (enabled?: boolean) => { if (cameraToggle.enabled) { cameraToggle.toggle(false); } await screenShareToggle.toggle(enabled); }, - [screenShareToggle.enabled, cameraToggle.enabled] + [cameraToggle, screenShareToggle] + ); + const handleMicrophoneDeviceSelectError = useCallback( + (error: Error) => onDeviceError?.({ source: Track.Source.Microphone, error }), + [onDeviceError] + ); + + const handleCameraDeviceSelectError = useCallback( + (error: Error) => onDeviceError?.({ source: Track.Source.Camera, error }), + [onDeviceError] ); return { micTrackRef, - visibleControls, cameraToggle: { ...cameraToggle, toggle: handleToggleCamera, @@ -145,8 +128,9 @@ export function useAgentControlBar(props: UseAgentControlBarProps = {}): UseAgen ...screenShareToggle, toggle: handleToggleScreenShare, }, - handleDisconnect, handleAudioDeviceChange, handleVideoDeviceChange, + handleMicrophoneDeviceSelectError, + handleCameraDeviceSelectError, }; } diff --git a/components/livekit/device-select.tsx b/components/livekit/agent-control-bar/track-device-select.tsx similarity index 67% rename from components/livekit/device-select.tsx rename to components/livekit/agent-control-bar/track-device-select.tsx index c48eb50d..f0373f55 100644 --- a/components/livekit/device-select.tsx +++ b/components/livekit/agent-control-bar/track-device-select.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useLayoutEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { cva } from 'class-variance-authority'; import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'; import { useMaybeRoomContext, useMediaDeviceSelect } from '@livekit/components-react'; @@ -10,25 +10,21 @@ import { SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select'; +} from '@/components/livekit/select'; import { cn } from '@/lib/utils'; type DeviceSelectProps = React.ComponentProps & { kind: MediaDeviceKind; + variant?: 'default' | 'small'; track?: LocalAudioTrack | LocalVideoTrack | undefined; requestPermissions?: boolean; onMediaDeviceError?: (error: Error) => void; - initialSelection?: string; - onActiveDeviceChange?: (deviceId: string) => void; onDeviceListChange?: (devices: MediaDeviceInfo[]) => void; - variant?: 'default' | 'small'; + onActiveDeviceChange?: (deviceId: string) => void; }; const selectVariants = cva( - [ - 'w-full rounded-full px-3 py-2 text-sm cursor-pointer', - 'disabled:not-allowed hover:bg-button-hover focus:bg-button-hover', - ], + 'w-full rounded-full px-3 py-2 text-sm cursor-pointer disabled:not-allowed', { variants: { size: { @@ -42,30 +38,31 @@ const selectVariants = cva( } ); -export function DeviceSelect({ +export function TrackDeviceSelect({ kind, track, - requestPermissions, + size = 'default', + requestPermissions = false, onMediaDeviceError, - // initialSelection, - // onActiveDeviceChange, - // onDeviceListChange, + onDeviceListChange, + onActiveDeviceChange, ...props }: DeviceSelectProps) { - const size = props.size || 'default'; - + const room = useMaybeRoomContext(); const [open, setOpen] = useState(false); const [requestPermissionsState, setRequestPermissionsState] = useState(requestPermissions); - - const room = useMaybeRoomContext(); const { devices, activeDeviceId, setActiveMediaDevice } = useMediaDeviceSelect({ - kind, room, + kind, track, requestPermissions: requestPermissionsState, onError: onMediaDeviceError, }); + useEffect(() => { + onDeviceListChange?.(devices); + }, [devices, onDeviceListChange]); + // When the select opens, ensure that media devices are re-requested in case when they were last // requested, permissions were not granted useLayoutEffect(() => { @@ -74,12 +71,23 @@ export function DeviceSelect({ } }, [open]); + const handleActiveDeviceChange = (deviceId: string) => { + setActiveMediaDevice(deviceId); + onActiveDeviceChange?.(deviceId); + }; + + const filteredDevices = useMemo(() => devices.filter((d) => d.deviceId !== ''), [devices]); + + if (filteredDevices.length < 2) { + return null; + } + return ( ); diff --git a/components/livekit/agent-control-bar/track-selector.tsx b/components/livekit/agent-control-bar/track-selector.tsx new file mode 100644 index 00000000..fdd0a3f7 --- /dev/null +++ b/components/livekit/agent-control-bar/track-selector.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { + BarVisualizer, + type TrackReferenceOrPlaceholder, + useTrackToggle, +} from '@livekit/components-react'; +import { TrackDeviceSelect } from '@/components/livekit/agent-control-bar/track-device-select'; +import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle'; +import { cn } from '@/lib/utils'; + +interface TrackSelectorProps { + kind: MediaDeviceKind; + source: Parameters[0]['source']; + pressed?: boolean; + pending?: boolean; + disabled?: boolean; + className?: string; + audioTrackRef?: TrackReferenceOrPlaceholder; + onPressedChange?: (pressed: boolean) => void; + onMediaDeviceError?: (error: Error) => void; + onActiveDeviceChange?: (deviceId: string) => void; +} + +export function TrackSelector({ + kind, + source, + pressed, + pending, + disabled, + className, + audioTrackRef, + onPressedChange, + onMediaDeviceError, + onActiveDeviceChange, +}: TrackSelectorProps) { + return ( +
+ + {audioTrackRef && ( + + + + )} + +
+ +
+ ); +} diff --git a/components/livekit/track-toggle.tsx b/components/livekit/agent-control-bar/track-toggle.tsx similarity index 96% rename from components/livekit/track-toggle.tsx rename to components/livekit/agent-control-bar/track-toggle.tsx index f205b263..e861d529 100644 --- a/components/livekit/track-toggle.tsx +++ b/components/livekit/agent-control-bar/track-toggle.tsx @@ -11,14 +11,9 @@ import { VideoCameraIcon, VideoCameraSlashIcon, } from '@phosphor-icons/react/dist/ssr'; -import { Toggle } from '@/components/ui/toggle'; +import { Toggle } from '@/components/livekit/toggle'; import { cn } from '@/lib/utils'; -export type TrackToggleProps = React.ComponentProps & { - source: Parameters[0]['source']; - pending?: boolean; -}; - function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) { if (pending) { return SpinnerIcon; @@ -36,6 +31,11 @@ function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) } } +export type TrackToggleProps = React.ComponentProps & { + source: Parameters[0]['source']; + pending?: boolean; +}; + export function TrackToggle({ source, pressed, pending, className, ...props }: TrackToggleProps) { const IconComponent = getSourceIcon(source, pressed ?? false, pending); diff --git a/components/livekit/agent-tile.tsx b/components/livekit/agent-tile.tsx deleted file mode 100644 index a23fa151..00000000 --- a/components/livekit/agent-tile.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { type AgentState, BarVisualizer, type TrackReference } from '@livekit/components-react'; -import { cn } from '@/lib/utils'; - -interface AgentAudioTileProps { - state: AgentState; - audioTrack: TrackReference; - className?: string; -} - -export const AgentTile = ({ - state, - audioTrack, - className, - ref, -}: React.ComponentProps<'div'> & AgentAudioTileProps) => { - return ( -
- - - -
- ); -}; diff --git a/components/alert-toast.tsx b/components/livekit/alert-toast.tsx similarity index 85% rename from components/alert-toast.tsx rename to components/livekit/alert-toast.tsx index f9f81f58..0d09ccb8 100644 --- a/components/alert-toast.tsx +++ b/components/livekit/alert-toast.tsx @@ -3,7 +3,7 @@ import { ReactNode } from 'react'; import { toast as sonnerToast } from 'sonner'; import { WarningIcon } from '@phosphor-icons/react/dist/ssr'; -import { Alert, AlertDescription, AlertTitle } from './ui/alert'; +import { Alert, AlertDescription, AlertTitle } from '@/components/livekit/alert'; interface ToastProps { id: string | number; @@ -18,7 +18,7 @@ export function toastAlert(toast: Omit) { ); } -function AlertToast(props: ToastProps) { +export function AlertToast(props: ToastProps) { const { title, description, id } = props; return ( diff --git a/components/ui/alert.tsx b/components/livekit/alert.tsx similarity index 90% rename from components/ui/alert.tsx rename to components/livekit/alert.tsx index 75b58f69..74e94ed9 100644 --- a/components/ui/alert.tsx +++ b/components/livekit/alert.tsx @@ -12,8 +12,8 @@ const alertVariants = cva( variant: { default: 'bg-card text-card-foreground', destructive: [ - 'text-destructive-foreground bg-destructive border-destructive-border', - '[&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/90', + 'text-destructive bg-destructive/10 border-destructive/20', + '[&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', ], }, }, @@ -61,4 +61,4 @@ function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) ); } -export { Alert, AlertTitle, AlertDescription }; +export { Alert, AlertTitle, AlertDescription, alertVariants }; diff --git a/components/livekit/avatar-tile.tsx b/components/livekit/avatar-tile.tsx deleted file mode 100644 index 7a7c7240..00000000 --- a/components/livekit/avatar-tile.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { type TrackReference, VideoTrack } from '@livekit/components-react'; -import { cn } from '@/lib/utils'; - -interface AgentAudioTileProps { - videoTrack: TrackReference; - className?: string; -} - -export const AvatarTile = ({ - videoTrack, - className, - ref, -}: React.ComponentProps<'div'> & AgentAudioTileProps) => { - return ( -
- -
- ); -}; diff --git a/components/ui/button.tsx b/components/livekit/button.tsx similarity index 80% rename from components/ui/button.tsx rename to components/livekit/button.tsx index 3f44d32c..3a6fb38c 100644 --- a/components/ui/button.tsx +++ b/components/livekit/button.tsx @@ -15,19 +15,19 @@ const buttonVariants = cva( { variants: { variant: { - default: 'bg-button text-button-foreground hover:bg-muted focus:bg-muted', + default: 'bg-muted text-foreground hover:bg-muted focus:bg-muted hover:bg-foreground/10', destructive: [ - 'bg-destructive text-destructive-foreground', - 'hover:bg-destructive-hover focus:bg-destructive-hover focus-visible:ring-destructive-foreground/20', - 'dark:focus-visible:ring-destructive-foreground/40', + 'bg-destructive/10 text-destructive', + 'hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20', + 'dark:focus-visible:ring-destructive/40', ], outline: [ - 'border bg-background', + 'border border-input bg-background', 'hover:bg-accent hover:text-accent-foreground', 'dark:bg-input/30 dark:border-input dark:hover:bg-input/50', ], - primary: 'bg-primary text-primary-foreground hover:bg-primary-hover focus:bg-primary-hover', - secondary: 'bg-secondary text-secondary-foregroun hover:bg-secondary/80', + primary: 'bg-primary text-primary-foreground hover:bg-primary/70 focus:bg-primary/70', + secondary: 'bg-foreground/15 text-secondary-foreground hover:bg-foreground/20', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, diff --git a/components/livekit/chat-entry.tsx b/components/livekit/chat-entry.tsx new file mode 100644 index 00000000..c9a7fbfa --- /dev/null +++ b/components/livekit/chat-entry.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface ChatEntryProps extends React.HTMLAttributes { + /** The locale to use for the timestamp. */ + locale: string; + /** The timestamp of the message. */ + timestamp: number; + /** The message to display. */ + message: string; + /** The origin of the message. */ + messageOrigin: 'local' | 'remote'; + /** The sender's name. */ + name?: string; + /** Whether the message has been edited. */ + hasBeenEdited?: boolean; +} + +export const ChatEntry = ({ + name, + locale, + timestamp, + message, + messageOrigin, + hasBeenEdited = false, + className, + ...props +}: ChatEntryProps) => { + const time = new Date(timestamp); + const title = time.toLocaleTimeString(locale, { timeStyle: 'full' }); + + return ( +
  • +
    + {name && {name}} + + {hasBeenEdited && '*'} + {time.toLocaleTimeString(locale, { timeStyle: 'short' })} + +
    + + {message} + +
  • + ); +}; diff --git a/components/livekit/chat/chat-entry.tsx b/components/livekit/chat/chat-entry.tsx deleted file mode 100644 index 1ad1ab84..00000000 --- a/components/livekit/chat/chat-entry.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; -import type { MessageFormatter, ReceivedChatMessage } from '@livekit/components-react'; -import { cn } from '@/lib/utils'; -import { useChatMessage } from './hooks/utils'; - -export interface ChatEntryProps extends React.HTMLAttributes { - /** The chat massage object to display. */ - entry: ReceivedChatMessage; - /** Hide sender name. Useful when displaying multiple consecutive chat messages from the same person. */ - hideName?: boolean; - /** Hide message timestamp. */ - hideTimestamp?: boolean; - /** An optional formatter for the message body. */ - messageFormatter?: MessageFormatter; -} - -export const ChatEntry = ({ - entry, - messageFormatter, - hideName, - hideTimestamp, - className, - ...props -}: ChatEntryProps) => { - const { message, hasBeenEdited, time, locale, name } = useChatMessage(entry, messageFormatter); - - const isUser = entry.from?.isLocal ?? false; - const messageOrigin = isUser ? 'remote' : 'local'; - - return ( -
  • - {(!hideTimestamp || !hideName || hasBeenEdited) && ( - - {!hideName && {name}} - - {!hideTimestamp && ( - - {hasBeenEdited && '*'} - {time.toLocaleTimeString(locale, { timeStyle: 'short' })} - - )} - - )} - - - {message} - -
  • - ); -}; diff --git a/components/livekit/chat/chat-input.tsx b/components/livekit/chat/chat-input.tsx deleted file mode 100644 index c23a7536..00000000 --- a/components/livekit/chat/chat-input.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -interface ChatInputProps extends React.HTMLAttributes { - onSend?: (message: string) => void; - disabled?: boolean; -} - -export function ChatInput({ onSend, className, disabled, ...props }: ChatInputProps) { - const inputRef = useRef(null); - const [message, setMessage] = useState(''); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - props.onSubmit?.(e); - onSend?.(message); - setMessage(''); - }; - - const isDisabled = disabled || message.trim().length === 0; - - useEffect(() => { - if (disabled) return; - // when not disabled refocus on input - inputRef.current?.focus(); - }, [disabled]); - - return ( -
    - setMessage(e.target.value)} - className="flex-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" - /> - -
    - ); -} diff --git a/components/livekit/chat/chat-message-view.tsx b/components/livekit/chat/chat-message-view.tsx deleted file mode 100644 index f3ce24c1..00000000 --- a/components/livekit/chat/chat-message-view.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { type RefObject, useEffect, useRef } from 'react'; -import { cn } from '@/lib/utils'; - -export function useAutoScroll(scrollContentContainerRef: RefObject) { - useEffect(() => { - function scrollToBottom() { - const { scrollingElement } = document; - - if (scrollingElement) { - scrollingElement.scrollTop = scrollingElement.scrollHeight; - } - } - - if (scrollContentContainerRef.current) { - const resizeObserver = new ResizeObserver(scrollToBottom); - - resizeObserver.observe(scrollContentContainerRef.current); - scrollToBottom(); - - return () => resizeObserver.disconnect(); - } - }, [scrollContentContainerRef]); -} -interface ChatProps extends React.HTMLAttributes { - children?: React.ReactNode; - className?: string; -} - -export const ChatMessageView = ({ className, children, ...props }: ChatProps) => { - const scrollContentRef = useRef(null); - - useAutoScroll(scrollContentRef); - - return ( -
    - {children} -
    - ); -}; diff --git a/components/livekit/chat/hooks/utils.ts b/components/livekit/chat/hooks/utils.ts deleted file mode 100644 index 26dbddd5..00000000 --- a/components/livekit/chat/hooks/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from 'react'; -import type { MessageFormatter, ReceivedChatMessage } from '@livekit/components-react'; - -export const useChatMessage = (entry: ReceivedChatMessage, messageFormatter?: MessageFormatter) => { - const formattedMessage = React.useMemo(() => { - return messageFormatter ? messageFormatter(entry.message) : entry.message; - }, [entry.message, messageFormatter]); - const hasBeenEdited = !!entry.editTimestamp; - const time = new Date(entry.timestamp); - const locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US'; - - const name = entry.from?.name && entry.from.name !== '' ? entry.from.name : entry.from?.identity; - - return { message: formattedMessage, hasBeenEdited, time, locale, name }; -}; diff --git a/components/livekit/media-tiles.tsx b/components/livekit/media-tiles.tsx deleted file mode 100644 index 7b7cedd6..00000000 --- a/components/livekit/media-tiles.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import React, { useMemo } from 'react'; -import { Track } from 'livekit-client'; -import { AnimatePresence, motion } from 'motion/react'; -import { - type TrackReference, - useLocalParticipant, - useTracks, - useVoiceAssistant, -} from '@livekit/components-react'; -import { cn } from '@/lib/utils'; -import { AgentTile } from './agent-tile'; -import { AvatarTile } from './avatar-tile'; -import { VideoTile } from './video-tile'; - -const MotionVideoTile = motion.create(VideoTile); -const MotionAgentTile = motion.create(AgentTile); -const MotionAvatarTile = motion.create(AvatarTile); - -const animationProps = { - initial: { - opacity: 0, - scale: 0, - }, - animate: { - opacity: 1, - scale: 1, - }, - exit: { - opacity: 0, - scale: 0, - }, - transition: { - type: 'spring', - stiffness: 675, - damping: 75, - mass: 1, - }, -}; - -const classNames = { - // GRID - // 2 Columns x 3 Rows - grid: [ - 'h-full w-full', - 'grid gap-x-2 place-content-center', - 'grid-cols-[1fr_1fr] grid-rows-[90px_1fr_90px]', - ], - // Agent - // chatOpen: true, - // hasSecondTile: true - // layout: Column 1 / Row 1 - // align: x-end y-center - agentChatOpenWithSecondTile: ['col-start-1 row-start-1', 'self-center justify-self-end'], - // Agent - // chatOpen: true, - // hasSecondTile: false - // layout: Column 1 / Row 1 / Column-Span 2 - // align: x-center y-center - agentChatOpenWithoutSecondTile: ['col-start-1 row-start-1', 'col-span-2', 'place-content-center'], - // Agent - // chatOpen: false - // layout: Column 1 / Row 1 / Column-Span 2 / Row-Span 3 - // align: x-center y-center - agentChatClosed: ['col-start-1 row-start-1', 'col-span-2 row-span-3', 'place-content-center'], - // Second tile - // chatOpen: true, - // hasSecondTile: true - // layout: Column 2 / Row 1 - // align: x-start y-center - secondTileChatOpen: ['col-start-2 row-start-1', 'self-center justify-self-start'], - // Second tile - // chatOpen: false, - // hasSecondTile: false - // layout: Column 2 / Row 2 - // align: x-end y-end - secondTileChatClosed: ['col-start-2 row-start-3', 'place-content-end'], -}; - -export function useLocalTrackRef(source: Track.Source) { - const { localParticipant } = useLocalParticipant(); - const publication = localParticipant.getTrackPublication(source); - const trackRef = useMemo( - () => (publication ? { source, participant: localParticipant, publication } : undefined), - [source, publication, localParticipant] - ); - return trackRef; -} - -interface MediaTilesProps { - chatOpen: boolean; -} - -export function MediaTiles({ chatOpen }: MediaTilesProps) { - const { - state: agentState, - audioTrack: agentAudioTrack, - videoTrack: agentVideoTrack, - } = useVoiceAssistant(); - const [screenShareTrack] = useTracks([Track.Source.ScreenShare]); - const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera); - - const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted; - const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted; - const hasSecondTile = isCameraEnabled || isScreenShareEnabled; - - const transition = { - ...animationProps.transition, - delay: chatOpen ? 0 : 0.15, // delay on close - }; - const agentAnimate = { - ...animationProps.animate, - scale: chatOpen ? 1 : 3, - transition, - }; - const avatarAnimate = { - ...animationProps.animate, - transition, - }; - const agentLayoutTransition = transition; - const avatarLayoutTransition = transition; - - const isAvatar = agentVideoTrack !== undefined; - - return ( -
    -
    -
    - {/* agent */} -
    - - {!isAvatar && ( - // audio-only agent - - )} - {isAvatar && ( - // avatar agent - video]:h-[90px] [&>video]:w-auto' : 'h-auto w-full' - )} - /> - )} - -
    - -
    - {/* camera */} - - {cameraTrack && isCameraEnabled && ( - - )} - {/* screen */} - {isScreenShareEnabled && ( - - )} - -
    -
    -
    -
    - ); -} diff --git a/components/livekit/scroll-area/hooks/useAutoScroll.ts b/components/livekit/scroll-area/hooks/useAutoScroll.ts new file mode 100644 index 00000000..b78404f4 --- /dev/null +++ b/components/livekit/scroll-area/hooks/useAutoScroll.ts @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; + +const AUTO_SCROLL_THRESHOLD_PX = 50; + +export function useAutoScroll(scrollContentContainer?: Element | null) { + useEffect(() => { + function scrollToBottom() { + if (!scrollContentContainer) return; + + const distanceFromBottom = + scrollContentContainer.scrollHeight - + scrollContentContainer.clientHeight - + scrollContentContainer.scrollTop; + + if (distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX) { + scrollContentContainer.scrollTop = scrollContentContainer.scrollHeight; + } + } + + if (scrollContentContainer && scrollContentContainer.firstElementChild) { + const resizeObserver = new ResizeObserver(scrollToBottom); + + resizeObserver.observe(scrollContentContainer.firstElementChild); + scrollToBottom(); + + return () => resizeObserver.disconnect(); + } + }, [scrollContentContainer]); +} diff --git a/components/livekit/scroll-area/scroll-area.tsx b/components/livekit/scroll-area/scroll-area.tsx new file mode 100644 index 00000000..8868d06e --- /dev/null +++ b/components/livekit/scroll-area/scroll-area.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useRef } from 'react'; +import { useAutoScroll } from '@/components/livekit/scroll-area/hooks/useAutoScroll'; +import { cn } from '@/lib/utils'; + +interface ScrollAreaProps { + children?: React.ReactNode; +} + +export function ScrollArea({ + className, + children, +}: ScrollAreaProps & React.HTMLAttributes) { + const scrollContentRef = useRef(null); + + useAutoScroll(scrollContentRef.current); + + return ( +
    +
    {children}
    +
    + ); +} diff --git a/components/ui/select.tsx b/components/livekit/select.tsx similarity index 96% rename from components/ui/select.tsx rename to components/livekit/select.tsx index 7d3e2c8e..92941de4 100644 --- a/components/ui/select.tsx +++ b/components/livekit/select.tsx @@ -33,9 +33,9 @@ function SelectTrigger({ [ 'flex w-fit cursor-pointer items-center justify-between gap-2', 'rounded-full px-3 py-2 text-sm whitespace-nowrap', - 'bg-button transition-[color,border,background-color]', + 'bg-muted transition-[color,border,background-color]', 'disabled:cursor-not-allowed disabled:opacity-50', - 'hover:bg-muted focus:bg-muted hover:text-muted-foreground focus:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]', + 'focus-visible:border-ring focus-visible:ring-ring/50 hover:bg-foreground/10 focus:bg-foreground/10 outline-none focus-visible:ring-[3px]', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', 'data-[placeholder]:text-muted-foreground', 'data-[size=default]:h-9 data-[size=sm]:h-8', diff --git a/components/livekit/shimmer-text.tsx b/components/livekit/shimmer-text.tsx new file mode 100644 index 00000000..a2a6905e --- /dev/null +++ b/components/livekit/shimmer-text.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface ShimmerTextProps { + children: React.ReactNode; + className?: string; +} + +export function ShimmerText({ + children, + className, + ref, +}: ShimmerTextProps & React.RefAttributes) { + return ( + + {children} + + ); +} + +export default ShimmerText; diff --git a/components/ui/sonner.tsx b/components/livekit/toaster.tsx similarity index 89% rename from components/ui/sonner.tsx rename to components/livekit/toaster.tsx index 76b13a36..8a57d95e 100644 --- a/components/ui/sonner.tsx +++ b/components/livekit/toaster.tsx @@ -4,7 +4,7 @@ import { useTheme } from 'next-themes'; import { Toaster as Sonner, ToasterProps } from 'sonner'; import { WarningIcon } from '@phosphor-icons/react/dist/ssr'; -const Toaster = ({ ...props }: ToasterProps) => { +export function Toaster({ ...props }: ToasterProps) { const { theme = 'system' } = useTheme(); return ( @@ -25,6 +25,4 @@ const Toaster = ({ ...props }: ToasterProps) => { {...props} /> ); -}; - -export { Toaster }; +} diff --git a/components/ui/toggle.tsx b/components/livekit/toggle.tsx similarity index 51% rename from components/ui/toggle.tsx rename to components/livekit/toggle.tsx index e363fdd7..f228a1cf 100644 --- a/components/ui/toggle.tsx +++ b/components/livekit/toggle.tsx @@ -9,31 +9,30 @@ const toggleVariants = cva( [ 'inline-flex items-center justify-center gap-2 rounded-full', 'text-sm font-medium whitespace-nowrap', - 'cursor-pointer outline-none transition-[color,border,background-color]', + 'cursor-pointer outline-none transition-[color,box-shadow,background-color]', + 'hover:bg-muted hover:text-muted-foreground', + 'disabled:pointer-events-none disabled:opacity-50', + 'data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', 'focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring', - 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', - 'disabled:pointer-events-none disabled:opacity-50 disabled:not-allowed', - 'data-[state=on]:bg-button-selected data-[state=on]:border-button-border-selected', + 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive ', "[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0", ], { variants: { variant: { - default: - 'bg-button hover:bg-muted focus:bg-muted hover:text-muted-foreground focus:text-muted-foreground', + default: 'bg-transparent', primary: - 'text-fg1 bg-button hover:bg-button-hover focus:bg-button-hover data-[state=off]:bg-button-primary hover:data-[state=off]:bg-button-hover data-[state=off]:text-button-primary-foreground', + 'bg-muted data-[state=on]:bg-muted hover:text-foreground text-destructive hover:text-foreground hover:bg-foreground/10 hover:data-[state=on]:bg-foreground/10', secondary: - 'text-fg1 bg-button hover:bg-button-hover focus:bg-button-hover data-[state=on]:bg-button-secondary hover:data-[state=on]:bg-button-secondary data-[state=on]:text-button-secondary-foreground', - outline: [ - 'border border-button-border bg-button text-button-foreground', - 'hover:bg-background focus:bg-background', - ], + 'bg-muted data-[state=on]:bg-muted hover:text-foreground hover:bg-foreground/10 hover:data-[state=on]:bg-foreground/10 data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30 data-[state=on]:text-blue-700 dark:data-[state=on]:text-blue-300', + outline: + 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', }, size: { - default: 'h-9 px-2 min-w-9', - sm: 'h-8 px-1.5 min-w-8', - lg: 'h-10 px-2.5 min-w-10', + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 px-6 has-[>svg]:px-4', + icon: 'size-9', }, }, defaultVariants: { diff --git a/components/livekit/video-tile.tsx b/components/livekit/video-tile.tsx deleted file mode 100644 index 90fd3215..00000000 --- a/components/livekit/video-tile.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { motion } from 'motion/react'; -import { VideoTrack } from '@livekit/components-react'; -import { cn } from '@/lib/utils'; - -const MotionVideoTrack = motion.create(VideoTrack); - -export const VideoTile = ({ - trackRef, - className, - ref, -}: React.ComponentProps<'div'> & React.ComponentProps) => { - return ( -
    - -
    - ); -}; diff --git a/components/provider.tsx b/components/provider.tsx deleted file mode 100644 index 59b7ab71..00000000 --- a/components/provider.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import React from 'react'; -import { Room } from 'livekit-client'; -import { RoomContext } from '@livekit/components-react'; -import { toastAlert } from '@/components/alert-toast'; -import useConnectionDetails from '@/hooks/useConnectionDetails'; -import { AppConfig } from '@/lib/types'; - -export function Provider({ - appConfig, - children, -}: { - appConfig: AppConfig; - children: React.ReactNode; -}) { - const { connectionDetails } = useConnectionDetails(appConfig); - const room = React.useMemo(() => new Room(), []); - - React.useEffect(() => { - if (room.state === 'disconnected' && connectionDetails) { - Promise.all([ - room.localParticipant.setMicrophoneEnabled(true, undefined, { - preConnectBuffer: true, - }), - room.connect(connectionDetails.serverUrl, connectionDetails.participantToken), - ]).catch((error) => { - toastAlert({ - title: 'There was an error connecting to the agent', - description: `${error.name}: ${error.message}`, - }); - }); - } - return () => { - room.disconnect(); - }; - }, [room, connectionDetails]); - - return {children}; -} diff --git a/components/session-view.tsx b/components/session-view.tsx deleted file mode 100644 index 2f9c9e9c..00000000 --- a/components/session-view.tsx +++ /dev/null @@ -1,178 +0,0 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; -import { AnimatePresence, motion } from 'motion/react'; -import { - type AgentState, - type ReceivedChatMessage, - useRoomContext, - useVoiceAssistant, -} from '@livekit/components-react'; -import { toastAlert } from '@/components/alert-toast'; -import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar'; -import { ChatEntry } from '@/components/livekit/chat/chat-entry'; -import { ChatMessageView } from '@/components/livekit/chat/chat-message-view'; -import { MediaTiles } from '@/components/livekit/media-tiles'; -import useChatAndTranscription from '@/hooks/useChatAndTranscription'; -import { useDebugMode } from '@/hooks/useDebug'; -import type { AppConfig } from '@/lib/types'; -import { cn } from '@/lib/utils'; - -function isAgentAvailable(agentState: AgentState) { - return agentState == 'listening' || agentState == 'thinking' || agentState == 'speaking'; -} - -interface SessionViewProps { - appConfig: AppConfig; - disabled: boolean; - sessionStarted: boolean; -} - -export const SessionView = ({ - appConfig, - disabled, - sessionStarted, - ref, -}: React.ComponentProps<'div'> & SessionViewProps) => { - const { state: agentState } = useVoiceAssistant(); - const [chatOpen, setChatOpen] = useState(false); - const { messages, send } = useChatAndTranscription(); - const room = useRoomContext(); - - useDebugMode({ - enabled: process.env.NODE_END !== 'production', - }); - - async function handleSendMessage(message: string) { - await send(message); - } - - useEffect(() => { - if (sessionStarted) { - const timeout = setTimeout(() => { - if (!isAgentAvailable(agentState)) { - const reason = - agentState === 'connecting' - ? 'Agent did not join the room. ' - : 'Agent connected but did not complete initializing. '; - - toastAlert({ - title: 'Session ended', - description: ( -

    - {reason} - - See quickstart guide - - . -

    - ), - }); - room.disconnect(); - } - }, 20_000); - - return () => clearTimeout(timeout); - } - }, [agentState, sessionStarted, room]); - - const { supportsChatInput, supportsVideoInput, supportsScreenShare } = appConfig; - const capabilities = { - supportsChatInput, - supportsVideoInput, - supportsScreenShare, - }; - - return ( -
    - -
    - - {messages.map((message: ReceivedChatMessage) => ( - - - - ))} - -
    -
    - -
    - {/* skrim */} -
    -
    - - - -
    - -
    - {appConfig.isPreConnectBufferEnabled && ( - 0 ? 0 : 0.8, - duration: messages.length > 0 ? 0.2 : 0.5, - }, - }} - aria-hidden={messages.length > 0} - className={cn( - 'absolute inset-x-0 -top-12 text-center', - sessionStarted && messages.length === 0 && 'pointer-events-none' - )} - > -

    - Agent is listening, ask it a question -

    -
    - )} - - -
    - {/* skrim */} -
    - -
    -
    - ); -}; diff --git a/components/welcome.tsx b/components/welcome.tsx deleted file mode 100644 index f0516191..00000000 --- a/components/welcome.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -interface WelcomeProps { - disabled: boolean; - startButtonText: string; - onStartCall: () => void; -} - -export const Welcome = ({ - disabled, - startButtonText, - onStartCall, - ref, -}: React.ComponentProps<'div'> & WelcomeProps) => { - return ( -
    - - - - -

    - Chat live with your voice AI agent -

    - - -
    - ); -}; diff --git a/app/fonts/CommitMono-400-Italic.otf b/fonts/CommitMono-400-Italic.otf similarity index 100% rename from app/fonts/CommitMono-400-Italic.otf rename to fonts/CommitMono-400-Italic.otf diff --git a/app/fonts/CommitMono-400-Regular.otf b/fonts/CommitMono-400-Regular.otf similarity index 100% rename from app/fonts/CommitMono-400-Regular.otf rename to fonts/CommitMono-400-Regular.otf diff --git a/app/fonts/CommitMono-700-Italic.otf b/fonts/CommitMono-700-Italic.otf similarity index 100% rename from app/fonts/CommitMono-700-Italic.otf rename to fonts/CommitMono-700-Italic.otf diff --git a/app/fonts/CommitMono-700-Regular.otf b/fonts/CommitMono-700-Regular.otf similarity index 100% rename from app/fonts/CommitMono-700-Regular.otf rename to fonts/CommitMono-700-Regular.otf diff --git a/hooks/useChatAndTranscription.ts b/hooks/useChatMessages.ts similarity index 51% rename from hooks/useChatAndTranscription.ts rename to hooks/useChatMessages.ts index d556970f..5cad96a8 100644 --- a/hooks/useChatAndTranscription.ts +++ b/hooks/useChatMessages.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { Room } from 'livekit-client'; import { type ReceivedChatMessage, type TextStreamData, @@ -6,12 +7,25 @@ import { useRoomContext, useTranscriptions, } from '@livekit/components-react'; -import { transcriptionToChatMessage } from '@/lib/utils'; -export default function useChatAndTranscription() { - const transcriptions: TextStreamData[] = useTranscriptions(); +function transcriptionToChatMessage(textStream: TextStreamData, room: Room): ReceivedChatMessage { + return { + id: textStream.streamInfo.id, + timestamp: textStream.streamInfo.timestamp, + message: textStream.text, + from: + textStream.participantInfo.identity === room.localParticipant.identity + ? room.localParticipant + : Array.from(room.remoteParticipants.values()).find( + (p) => p.identity === textStream.participantInfo.identity + ), + }; +} + +export function useChatMessages() { const chat = useChat(); const room = useRoomContext(); + const transcriptions: TextStreamData[] = useTranscriptions(); const mergedTranscriptions = useMemo(() => { const merged: Array = [ @@ -21,5 +35,5 @@ export default function useChatAndTranscription() { return merged.sort((a, b) => a.timestamp - b.timestamp); }, [transcriptions, chat.chatMessages, room]); - return { messages: mergedTranscriptions, send: chat.send }; + return mergedTranscriptions; } diff --git a/hooks/useConnectionDetails.ts b/hooks/useConnectionDetails.ts deleted file mode 100644 index 5534c423..00000000 --- a/hooks/useConnectionDetails.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { decodeJwt } from 'jose'; -import { ConnectionDetails } from '@/app/api/connection-details/route'; -import { AppConfig } from '@/lib/types'; - -const ONE_MINUTE_IN_MILLISECONDS = 60 * 1000; - -export default function useConnectionDetails(appConfig: AppConfig) { - // 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. - - const [connectionDetails, setConnectionDetails] = useState(null); - - const fetchConnectionDetails = useCallback(async () => { - setConnectionDetails(null); - const url = new URL( - process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details', - window.location.origin - ); - - let data: ConnectionDetails; - try { - const res = await fetch(url.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Sandbox-Id': appConfig.sandboxId ?? '', - }, - body: JSON.stringify({ - room_config: appConfig.agentName - ? { - agents: [{ agent_name: appConfig.agentName }], - } - : undefined, - }), - }); - data = await res.json(); - } catch (error) { - console.error('Error fetching connection details:', error); - throw new Error('Error fetching connection details!'); - } - - setConnectionDetails(data); - return data; - }, []); - - useEffect(() => { - fetchConnectionDetails(); - }, [fetchConnectionDetails]); - - const isConnectionDetailsExpired = useCallback(() => { - const token = connectionDetails?.participantToken; - if (!token) { - return true; - } - - const jwtPayload = decodeJwt(token); - if (!jwtPayload.exp) { - return true; - } - const expiresAt = new Date(jwtPayload.exp * 1000 - ONE_MINUTE_IN_MILLISECONDS); - - const now = new Date(); - return expiresAt <= now; - }, [connectionDetails?.participantToken]); - - const existingOrRefreshConnectionDetails = useCallback(async () => { - if (isConnectionDetailsExpired() || !connectionDetails) { - return fetchConnectionDetails(); - } else { - return connectionDetails; - } - }, [connectionDetails, fetchConnectionDetails, isConnectionDetailsExpired]); - - return { - connectionDetails, - refreshConnectionDetails: fetchConnectionDetails, - existingOrRefreshConnectionDetails, - }; -} diff --git a/hooks/useConnectionTimout.tsx b/hooks/useConnectionTimout.tsx new file mode 100644 index 00000000..7a7c12f2 --- /dev/null +++ b/hooks/useConnectionTimout.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { type AgentState, useRoomContext, useVoiceAssistant } from '@livekit/components-react'; +import { toastAlert } from '@/components/livekit/alert-toast'; + +function isAgentAvailable(agentState: AgentState) { + return agentState == 'listening' || agentState == 'thinking' || agentState == 'speaking'; +} + +export function useConnectionTimeout(timout = 20_000) { + const room = useRoomContext(); + const { state: agentState } = useVoiceAssistant(); + + useEffect(() => { + const timeout = setTimeout(() => { + if (!isAgentAvailable(agentState)) { + const reason = + agentState === 'connecting' + ? 'Agent did not join the room. ' + : 'Agent connected but did not complete initializing. '; + + toastAlert({ + title: 'Session ended', + description: ( +

    + {reason} + + See quickstart guide + + . +

    + ), + }); + + room.disconnect(); + } + }, timout); + + return () => clearTimeout(timeout); + }, [agentState, room, timout]); +} diff --git a/hooks/useDebug.ts b/hooks/useDebug.ts index 9d4b7d53..b5b12878 100644 --- a/hooks/useDebug.ts +++ b/hooks/useDebug.ts @@ -15,11 +15,11 @@ export const useDebugMode = (options: { logLevel?: LogLevel; enabled?: boolean } setLogLevel(logLevel ?? 'debug'); - // @ts-expect-error + // @ts-expect-error this is a global variable window.__lk_room = room; return () => { - // @ts-expect-error + // @ts-expect-error this is a global variable window.__lk_room = undefined; setLogLevel('silent'); }; diff --git a/hooks/useRoom.ts b/hooks/useRoom.ts new file mode 100644 index 00000000..177a5d55 --- /dev/null +++ b/hooks/useRoom.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Room, RoomEvent, TokenSource } from 'livekit-client'; +import { AppConfig } from '@/app-config'; +import { toastAlert } from '@/components/livekit/alert-toast'; + +export function useRoom(appConfig: AppConfig) { + const aborted = useRef(false); + const room = useMemo(() => new Room(), []); + const [isSessionActive, setIsSessionActive] = useState(false); + + useEffect(() => { + function onDisconnected() { + setIsSessionActive(false); + } + + function onMediaDevicesError(error: Error) { + toastAlert({ + title: 'Encountered an error with your media devices', + description: `${error.name}: ${error.message}`, + }); + } + + room.on(RoomEvent.Disconnected, onDisconnected); + room.on(RoomEvent.MediaDevicesError, onMediaDevicesError); + + return () => { + room.off(RoomEvent.Disconnected, onDisconnected); + room.off(RoomEvent.MediaDevicesError, onMediaDevicesError); + }; + }, [room]); + + useEffect(() => { + return () => { + aborted.current = true; + room.disconnect(); + }; + }, [room]); + + const tokenSource = useMemo( + () => + TokenSource.custom(async () => { + const url = new URL( + process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details', + window.location.origin + ); + + try { + const res = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Sandbox-Id': appConfig.sandboxId ?? '', + }, + body: JSON.stringify({ + room_config: appConfig.agentName + ? { + agents: [{ agent_name: appConfig.agentName }], + } + : undefined, + }), + }); + return await res.json(); + } catch (error) { + console.error('Error fetching connection details:', error); + throw new Error('Error fetching connection details!'); + } + }), + [appConfig] + ); + + const startSession = useCallback(() => { + setIsSessionActive(true); + + if (room.state === 'disconnected') { + const { isPreConnectBufferEnabled } = appConfig; + Promise.all([ + room.localParticipant.setMicrophoneEnabled(true, undefined, { + preConnectBuffer: isPreConnectBufferEnabled, + }), + tokenSource + .fetch({ agentName: appConfig.agentName }) + .then((connectionDetails) => + room.connect(connectionDetails.serverUrl, connectionDetails.participantToken) + ), + ]).catch((error) => { + if (aborted.current) { + // Once the effect has cleaned up after itself, drop any errors + // + // These errors are likely caused by this effect rerunning rapidly, + // resulting in a previous run `disconnect` running in parallel with + // a current run `connect` + return; + } + + toastAlert({ + title: 'There was an error connecting to the agent', + description: `${error.name}: ${error.message}`, + }); + }); + } + }, [room, appConfig, tokenSource]); + + const endSession = useCallback(() => { + setIsSessionActive(false); + }, []); + + return { room, isSessionActive, startSession, endSession }; +} diff --git a/lib/types.ts b/lib/types.ts deleted file mode 100644 index 2dfccde3..00000000 --- a/lib/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { TranscriptionSegment } from 'livekit-client'; - -export interface CombinedTranscription extends TranscriptionSegment { - role: 'assistant' | 'user'; - receivedAtMediaTimestamp: number; - receivedAt: number; -} -export type ThemeMode = 'dark' | 'light' | 'system'; - -export interface AppConfig { - pageTitle: string; - pageDescription: string; - companyName: string; - - supportsChatInput: boolean; - supportsVideoInput: boolean; - supportsScreenShare: boolean; - isPreConnectBufferEnabled: boolean; - - logo: string; - startButtonText: string; - accent?: string; - logoDark?: string; - accentDark?: string; - - sandboxId?: string; - agentName?: string; -} - -export interface SandboxConfig { - [key: string]: - | { type: 'string'; value: string } - | { type: 'number'; value: number } - | { type: 'boolean'; value: boolean } - | null; -} diff --git a/lib/utils.ts b/lib/utils.ts index 44737ed3..5136555b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,10 +1,8 @@ import { cache } from 'react'; import { type ClassValue, clsx } from 'clsx'; -import { Room } from 'livekit-client'; import { twMerge } from 'tailwind-merge'; -import type { ReceivedChatMessage, TextStreamData } from '@livekit/components-react'; import { APP_CONFIG_DEFAULTS } from '@/app-config'; -import type { AppConfig, SandboxConfig } from './types'; +import type { AppConfig } from '@/app-config'; export const CONFIG_ENDPOINT = process.env.NEXT_PUBLIC_APP_CONFIG_ENDPOINT; export const SANDBOX_ID = process.env.SANDBOX_ID; @@ -12,25 +10,16 @@ export const SANDBOX_ID = process.env.SANDBOX_ID; export const THEME_STORAGE_KEY = 'theme-mode'; export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)'; -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); +export interface SandboxConfig { + [key: string]: + | { type: 'string'; value: string } + | { type: 'number'; value: number } + | { type: 'boolean'; value: boolean } + | null; } -export function transcriptionToChatMessage( - textStream: TextStreamData, - room: Room -): ReceivedChatMessage { - return { - id: textStream.streamInfo.id, - timestamp: textStream.streamInfo.timestamp, - message: textStream.text, - from: - textStream.participantInfo.identity === room.localParticipant.identity - ? room.localParticipant - : Array.from(room.remoteParticipants.values()).find( - (p) => p.identity === textStream.participantInfo.identity - ), - }; +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); } // https://react.dev/reference/react/cache#caveats @@ -50,7 +39,7 @@ export const getAppConfig = cache(async (headers: Headers): Promise = }); const remoteConfig: SandboxConfig = await response.json(); - const config: AppConfig = { sandboxId, ...APP_CONFIG_DEFAULTS }; + const config: AppConfig = { ...APP_CONFIG_DEFAULTS, sandboxId }; for (const [key, entry] of Object.entries(remoteConfig)) { if (entry === null) continue; @@ -75,3 +64,25 @@ export const getAppConfig = cache(async (headers: Headers): Promise = return APP_CONFIG_DEFAULTS; }); + +// check provided accent colors against defaults +// apply styles if they differ (or in development mode) +// generate a hover color for the accent color by mixing it with 20% black +export function getStyles(appConfig: AppConfig) { + const { accent, accentDark } = appConfig; + const hasCustomAccentColor = + process.env.NODE_ENV === 'development' || accent !== APP_CONFIG_DEFAULTS.accent; + const hasCustomAccentDarkColor = + process.env.NODE_ENV === 'development' || accentDark !== APP_CONFIG_DEFAULTS.accentDark; + + return [ + hasCustomAccentColor + ? `:root { --primary: ${accent}; --primary-hover: color-mix(in srgb, ${accent} 80%, #000); }` + : '', + hasCustomAccentDarkColor + ? `.dark { --primary: ${accentDark}; --primary-hover: color-mix(in srgb, ${accentDark} 80%, #000); }` + : '', + ] + .filter(Boolean) + .join('\n'); +} diff --git a/package.json b/package.json index c8977fd2..0c43a95f 100644 --- a/package.json +++ b/package.json @@ -11,20 +11,17 @@ "format:check": "prettier --check ." }, "dependencies": { - "@livekit/components-react": "^2.9.14", + "@livekit/components-react": "^2.9.15", "@livekit/protocol": "^1.40.0", "@phosphor-icons/react": "^2.1.8", - "@radix-ui/react-label": "^2.1.7", - "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.9", - "@radix-ui/react-toolbar": "^1.1.10", "buffer-image-size": "^0.6.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^6.0.12", - "livekit-client": "^2.15.5", + "livekit-client": "^2.15.8", "livekit-server-sdk": "^2.13.2", "mime": "^4.0.7", "motion": "^12.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c97e0e1..2a20acd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,20 +9,14 @@ importers: .: dependencies: '@livekit/components-react': - specifier: ^2.9.14 - version: 2.9.14(@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22)))(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tslib@2.8.1) + specifier: ^2.9.15 + version: 2.9.15(@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22)))(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tslib@2.8.1) '@livekit/protocol': specifier: ^1.40.0 version: 1.41.0 '@phosphor-icons/react': specifier: ^2.1.8 version: 2.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-label': - specifier: ^2.1.7 - version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-scroll-area': - specifier: ^1.2.9 - version: 1.2.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@radix-ui/react-select': specifier: ^2.2.5 version: 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -32,9 +26,6 @@ importers: '@radix-ui/react-toggle': specifier: ^1.1.9 version: 1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-toolbar': - specifier: ^1.1.10 - version: 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) buffer-image-size: specifier: ^0.6.4 version: 0.6.4 @@ -48,8 +39,8 @@ importers: specifier: ^6.0.12 version: 6.1.0 livekit-client: - specifier: ^2.15.5 - version: 2.15.6(@types/dom-mediacapture-record@1.0.22) + specifier: ^2.15.8 + version: 2.15.8(@types/dom-mediacapture-record@1.0.22) livekit-server-sdk: specifier: ^2.13.2 version: 2.13.3 @@ -216,9 +207,6 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.2': - resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==} - '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -409,15 +397,15 @@ packages: '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} - '@livekit/components-core@0.12.9': - resolution: {integrity: sha512-bwrZsHf6GaHIO+lLyA6Yps1STTX9YIeL3ixwt+Ufi88OgkNYdp41Ug8oeVDlf7tzdxa+r3Xkfaj/qvIG84Yo6A==} + '@livekit/components-core@0.12.10': + resolution: {integrity: sha512-lSGci8c8IB/qCi42g1tzNtDGpnBWH1XSSk/OA9Lzk7vqOG0LlkwD3zXfBeKfO2eWFmYRfrZ2GD59GaH2NtTgag==} engines: {node: '>=18'} peerDependencies: livekit-client: ^2.13.3 tslib: ^2.6.2 - '@livekit/components-react@2.9.14': - resolution: {integrity: sha512-fQ3t4PdcM+AORo62FWmJcfqWe7ODwVaU4nsqxse+fp6L5a+0K2uMD7yQ2jrutXIaUQigU/opzTUxPcpdk9+0ow==} + '@livekit/components-react@2.9.15': + resolution: {integrity: sha512-b+gA0sRJHMsyr/BoMBoY1vSXQmP3h5NmxZTUt+VG8xjzCYDjmUuiDUrKVwMIUoy1vK9I6uNfo+hp6qbLo84jfQ==} engines: {node: '>=18'} peerDependencies: '@livekit/krisp-noise-filter': ^0.2.12 || ^0.3.0 @@ -437,12 +425,12 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.39.3': - resolution: {integrity: sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==} - '@livekit/protocol@1.41.0': resolution: {integrity: sha512-bozBB39VSbd0IjRBwShMlLskqkd9weWJNskaB1CVpcEO9UUI1gMwAtBJOKYblzZJT9kE1SJa3L4oWWwsZMzSXw==} + '@livekit/protocol@1.42.0': + resolution: {integrity: sha512-42sYSCay2PZrn5yHHt+O3RQpTElcTrA7bqg7iYbflUApeerA5tUCJDr8Z4abHsYHVKjqVUbkBq/TPmT3X6aYOQ==} + '@napi-rs/wasm-runtime@0.2.10': resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==} @@ -630,19 +618,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-label@2.1.7': - resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -669,19 +644,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -695,32 +657,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -734,19 +670,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.7': - resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -756,19 +679,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-toggle-group@1.1.11': - resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-toggle@1.1.10': resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} peerDependencies: @@ -782,19 +692,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toolbar@1.1.11': - resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1979,8 +1876,8 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} - livekit-client@2.15.6: - resolution: {integrity: sha512-bLdNXklpMfWofw9pCF2XGyYA3OUddXXG4KY+gTN7dh+YvG7TX+YaP/Kt9ugdZ3KziQLqK2HG1ict4s7uD0JAiQ==} + livekit-client@2.15.8: + resolution: {integrity: sha512-M+GnlmoY+JOfGGhDov5f4V273YZ9DuWFBaPwz42fliC3TsFTzEcJoRqqE7uLtEGAnloqbLPk+sIvW/XSU4Z4/Q==} peerDependencies: '@types/dom-mediacapture-record': ^1 @@ -2779,17 +2676,13 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@floating-ui/core@1.7.2': - dependencies: - '@floating-ui/utils': 0.2.10 - '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 '@floating-ui/dom@1.6.13': dependencies: - '@floating-ui/core': 1.7.2 + '@floating-ui/core': 1.7.3 '@floating-ui/utils': 0.2.10 '@floating-ui/dom@1.7.3': @@ -2940,38 +2833,38 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@livekit/components-core@0.12.9(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': + '@livekit/components-core@0.12.10(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': dependencies: '@floating-ui/dom': 1.6.13 - livekit-client: 2.15.6(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.15.8(@types/dom-mediacapture-record@1.0.22) loglevel: 1.9.1 rxjs: 7.8.2 tslib: 2.8.1 - '@livekit/components-react@2.9.14(@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22)))(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tslib@2.8.1)': + '@livekit/components-react@2.9.15(@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22)))(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tslib@2.8.1)': dependencies: - '@livekit/components-core': 0.12.9(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) + '@livekit/components-core': 0.12.10(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) clsx: 2.1.1 - livekit-client: 2.15.6(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.15.8(@types/dom-mediacapture-record@1.0.22) react: 19.1.1 react-dom: 19.1.1(react@19.1.1) tslib: 2.8.1 usehooks-ts: 3.1.1(react@19.1.1) optionalDependencies: - '@livekit/krisp-noise-filter': 0.2.16(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22)) + '@livekit/krisp-noise-filter': 0.2.16(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22)) - '@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))': + '@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))': dependencies: - livekit-client: 2.15.6(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.15.8(@types/dom-mediacapture-record@1.0.22) optional: true '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.39.3': + '@livekit/protocol@1.41.0': dependencies: '@bufbuild/protobuf': 1.10.1 - '@livekit/protocol@1.41.0': + '@livekit/protocol@1.42.0': dependencies: '@bufbuild/protobuf': 1.10.1 @@ -3113,15 +3006,6 @@ snapshots: optionalDependencies: '@types/react': 19.1.12 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - optionalDependencies: - '@types/react': 19.1.12 - '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@floating-ui/react-dom': 2.1.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -3150,16 +3034,6 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1) - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - optionalDependencies: - '@types/react': 19.1.12 - '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1) @@ -3169,40 +3043,6 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1) - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - optionalDependencies: - '@types/react': 19.1.12 - '@types/react-dom': 19.1.9(@types/react@19.1.12) - - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1) - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - optionalDependencies: - '@types/react': 19.1.12 - '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/number': 1.1.1 @@ -3232,15 +3072,6 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - optionalDependencies: - '@types/react': 19.1.12 - '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-slot@1.2.3(@types/react@19.1.12)(react@19.1.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) @@ -3248,21 +3079,6 @@ snapshots: optionalDependencies: '@types/react': 19.1.12 - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1) - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - optionalDependencies: - '@types/react': 19.1.12 - '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3274,21 +3090,6 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - optionalDependencies: - '@types/react': 19.1.12 - '@types/react-dom': 19.1.9(@types/react@19.1.12) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.12)(react@19.1.1)': dependencies: react: 19.1.1 @@ -4577,12 +4378,13 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 - livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22): + livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22): dependencies: '@livekit/mutex': 1.1.1 - '@livekit/protocol': 1.39.3 + '@livekit/protocol': 1.42.0 '@types/dom-mediacapture-record': 1.0.22 events: 3.3.0 + jose: 6.1.0 loglevel: 1.9.2 sdp-transform: 2.15.0 ts-debounce: 4.0.0 diff --git a/app/globals.css b/styles/globals.css similarity index 62% rename from app/globals.css rename to styles/globals.css index 036b3940..a1b5f7d4 100644 --- a/app/globals.css +++ b/styles/globals.css @@ -4,33 +4,6 @@ @custom-variant dark (&:is(.dark *)); :root { - --fg0: #000000; - --fg1: #3b3b3b; - --fg2: #4d4d4d; - --fg3: #636363; - --fg4: #707070; - --fgSerious: #db1b06; - --fgSuccess: #006430; - --fgModerate: #a65006; - --fgAccent: #002cf2; - - --bg1: #f9f9f6; - --bg2: #f3f3f1; - --bg3: #e2e2df; - --bgSerious: #fae6e6; - --bgSerious2: #ffcdc7; - --bgSuccess: #d1fadf; - --bgModerate: #faedd1; - --bgAccent: #b3ccff; - --bgAccentPrimary: #e2ebfd; - - --separator1: #dbdbd8; - --separator2: #bdbdbb; - --separatorSerious: #ffcdc7; - --separatorSuccess: #94dcb5; - --separatorModerate: #fbd7a0; - --separatorAccent: #b3ccff; - --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); @@ -38,15 +11,15 @@ --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: #002cf2; - --primary-hover: #0020b9; + --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); - --muted: #f3f3f1; + --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); @@ -66,48 +39,21 @@ } .dark { - --fg0: #ffffff; - --fg1: #cccccc; - --fg2: #b2b2b2; - --fg3: #999999; - --fg4: #666666; - --fgSerious: #ff7566; - --fgSuccess: #3bc981; - --fgModerate: #ffb752; - --fgAccent: #6e9dfe; - - --bg1: #070707; - --bg2: #131313; - --bg3: #202020; - --bgSerious: #1f0e0b; - --bgSerious2: #5a1c16; - --bgSuccess: #001905; - --bgModerate: #1a0e04; - --bgAccent: #090c17; - --bgAccentPrimary: #0c1640; - - --separator1: #202020; - --separator2: #30302f; - --separatorSerious: #5a1c16; - --separatorSuccess: #003213; - --separatorModerate: #3f2208; - --separatorAccent: #0c1640; - --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.269 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: #1fd5f9; - --primary-hover: #19a7c7; + --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); - --muted: #131313; + --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.371 0 0); --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); @@ -127,31 +73,12 @@ } @theme inline { - --color-fg1: var(--fg1); - --color-fg2: var(--fg2); - --color-fg3: var(--fg3); - --color-fg4: var(--fg4); - --color-fgSerious: var(--fgSerious); - --color-fgSuccess: var(--fgSuccess); - --color-fgModerate: var(--fgModerate); - --color-fgAccent: var(--fgAccent); - - --color-bg1: var(--bg1); - --color-bg2: var(--bg2); - --color-bg3: var(--bg3); - --color-bgSerious: var(--bgSerious); - --color-bgSerious2: var(--bgSerious2); - --color-bgSuccess: var(--bgSuccess); - --color-bgModerate: var(--bgModerate); - --color-bgAccent: var(--bgAccent); - --color-bgAccentPrimary: var(--bgAccentPrimary); - - --color-separator1: var(--separator1); - --color-separator2: var(--separator2); - --color-separatorSerious: var(--separatorSerious); - --color-separatorSuccess: var(--separatorSuccess); - --color-separatorModerate: var(--separatorModerate); - --color-separatorAccent: var(--separatorAccent); + --font-sans: + var(--font-public-sans), ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-mono: + var(--font-commit-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; --color-background: var(--background); --color-foreground: var(--foreground); @@ -160,7 +87,6 @@ --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); - --color-primary-hover: var(--primary-hover); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); @@ -169,7 +95,7 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); - --color-border: var(--separator1); + --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); @@ -185,23 +111,11 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); - - --color-button: var(--bg2); - --color-button-hover: var(--bg3); - --color-button-foreground: var(--fg1); - --color-button-primary: var(--bg2); - --color-button-primary-foreground: var(--fgSerious); - --color-button-secondary: var(--bgAccentPrimary); - --color-button-secondary-foreground: var(--fgAccent); - - --color-destructive: var(--bgSerious); - --color-destructive-hover: var(--bgSerious2); - --color-destructive-foreground: var(--fgSerious); } @layer base { * { - @apply border-border outline-ring/50; + @apply border-foreground/20 outline-ring/50; } body { @apply bg-background text-foreground;