diff --git a/app-config.ts b/app-config.ts
index d41e8b52..87d97dea 100644
--- a/app-config.ts
+++ b/app-config.ts
@@ -1,22 +1,4 @@
-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;
-}
+import type { AppConfig } from './lib/types';
export const APP_CONFIG_DEFAULTS: AppConfig = {
companyName: 'LiveKit',
@@ -33,4 +15,6 @@ export const APP_CONFIG_DEFAULTS: AppConfig = {
logoDark: '/lk-logo-dark.svg',
accentDark: '#1fd5f9',
startButtonText: 'Start call',
+
+ agentName: undefined,
};
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index 1f56a3ff..25847feb 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 LayoutProps {
+interface AppLayoutProps {
children: React.ReactNode;
}
-export default async function Layout({ children }: LayoutProps) {
+export default async function AppLayout({ children }: AppLayoutProps) {
const hdrs = await headers();
const { companyName, logo, logoDark } = await getAppConfig(hdrs);
@@ -39,7 +39,6 @@ export default async function Layout({ children }: LayoutProps) {
-
{children}
>
);
diff --git a/app/(app)/opengraph-image.tsx b/app/(app)/opengraph-image.tsx
index 9fccff1e..a438c0b4 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 jsx-a11y/alt-text */}
+ {/* eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text */}
{/* logo */}
@@ -179,7 +179,7 @@ export default async function Image() {
gap: 10,
}}
>
- {/* eslint-disable-next-line jsx-a11y/alt-text */}
+ {/* eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text */}
{/* title */}
diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx
index f4655546..eb0e9c56 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/app';
+import { App } from '@/components/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 8f2c4841..02d6542a 100644
--- a/app/api/connection-details/route.ts
+++ b/app/api/connection-details/route.ts
@@ -2,13 +2,6 @@ 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;
@@ -17,6 +10,13 @@ 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
new file mode 100644
index 00000000..8c47b46b
--- /dev/null
+++ b/app/components/Container.tsx
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 00000000..8b7602da
--- /dev/null
+++ b/app/components/Tabs.tsx
@@ -0,0 +1,33 @@
+'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
new file mode 100644
index 00000000..53792483
--- /dev/null
+++ b/app/components/base/page.tsx
@@ -0,0 +1,132 @@
+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
new file mode 100644
index 00000000..a9240aef
--- /dev/null
+++ b/app/components/layout.tsx
@@ -0,0 +1,24 @@
+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 (
+
+ );
+}
diff --git a/app/components/livekit/page.tsx b/app/components/livekit/page.tsx
new file mode 100644
index 00000000..2905fe4d
--- /dev/null
+++ b/app/components/livekit/page.tsx
@@ -0,0 +1,66 @@
+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
new file mode 100644
index 00000000..eadebf51
--- /dev/null
+++ b/app/components/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from 'next/navigation';
+
+export default function Components() {
+ return redirect('/components/base');
+}
diff --git a/fonts/CommitMono-400-Italic.otf b/app/fonts/CommitMono-400-Italic.otf
similarity index 100%
rename from fonts/CommitMono-400-Italic.otf
rename to app/fonts/CommitMono-400-Italic.otf
diff --git a/fonts/CommitMono-400-Regular.otf b/app/fonts/CommitMono-400-Regular.otf
similarity index 100%
rename from fonts/CommitMono-400-Regular.otf
rename to app/fonts/CommitMono-400-Regular.otf
diff --git a/fonts/CommitMono-700-Italic.otf b/app/fonts/CommitMono-700-Italic.otf
similarity index 100%
rename from fonts/CommitMono-700-Italic.otf
rename to app/fonts/CommitMono-700-Italic.otf
diff --git a/fonts/CommitMono-700-Regular.otf b/app/fonts/CommitMono-700-Regular.otf
similarity index 100%
rename from fonts/CommitMono-700-Regular.otf
rename to app/fonts/CommitMono-700-Regular.otf
diff --git a/styles/globals.css b/app/globals.css
similarity index 62%
rename from styles/globals.css
rename to app/globals.css
index a1b5f7d4..036b3940 100644
--- a/styles/globals.css
+++ b/app/globals.css
@@ -4,6 +4,33 @@
@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);
@@ -11,15 +38,15 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
+ --primary: #002cf2;
+ --primary-hover: #0020b9;
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
+ --muted: #f3f3f1;
--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);
@@ -39,21 +66,48 @@
}
.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: oklch(0.922 0 0);
+ --primary: #1fd5f9;
+ --primary-hover: #19a7c7;
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
+ --muted: #131313;
--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);
@@ -73,12 +127,31 @@
}
@theme inline {
- --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-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);
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -87,6 +160,7 @@
--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);
@@ -95,7 +169,7 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
- --color-border: var(--border);
+ --color-border: var(--separator1);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
@@ -111,11 +185,23 @@
--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-foreground/20 outline-ring/50;
+ @apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
diff --git a/app/layout.tsx b/app/layout.tsx
index 171d4453..f43f71dd 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,9 +1,10 @@
import { Public_Sans } from 'next/font/google';
import localFont from 'next/font/local';
import { headers } from 'next/headers';
-import { ApplyThemeScript, ThemeToggle } from '@/components/app/theme-toggle';
-import { cn, getAppConfig, getStyles } from '@/lib/utils';
-import '@/styles/globals.css';
+import { APP_CONFIG_DEFAULTS } from '@/app-config';
+import { ApplyThemeScript, ThemeToggle } from '@/components/theme-toggle';
+import { getAppConfig } from '@/lib/utils';
+import './globals.css';
const publicSans = Public_Sans({
variable: '--font-public-sans',
@@ -11,30 +12,29 @@ 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,27 +43,32 @@ interface RootLayoutProps {
export default async function RootLayout({ children }: RootLayoutProps) {
const hdrs = await headers();
- const appConfig = await getAppConfig(hdrs);
- const { pageTitle, pageDescription } = appConfig;
- const styles = getStyles(appConfig);
+ 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');
return (
-
+
{styles && }
{pageTitle}
-
+
{children}
diff --git a/app/ui/layout.tsx b/app/ui/layout.tsx
deleted file mode 100644
index c7202785..00000000
--- a/app/ui/layout.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-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
deleted file mode 100644
index 83e1a7ba..00000000
--- a/app/ui/page.tsx
+++ /dev/null
@@ -1,268 +0,0 @@
-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 */}
-
-
-
-
- |
- Small |
- Default |
- Large |
- Icon |
-
-
-
- {['default', 'primary', 'secondary', 'outline', 'ghost', 'link', 'destructive'].map(
- (variant) => (
-
- | {variant} |
- {['sm', 'default', 'lg', 'icon'].map((size) => (
-
-
- |
- ))}
-
- )
- )}
-
-
-
-
- {/* Toggle */}
-
-
-
-
- |
- Small |
- Default |
- Large |
- Icon |
-
-
-
- {['default', 'primary', 'secondary', 'outline'].map((variant) => (
-
- | {variant} |
- {['sm', 'default', 'lg', 'icon'].map((size) => (
-
-
- {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/livekit/alert-toast.tsx b/components/alert-toast.tsx
similarity index 85%
rename from components/livekit/alert-toast.tsx
rename to components/alert-toast.tsx
index 0d09ccb8..f9f81f58 100644
--- a/components/livekit/alert-toast.tsx
+++ b/components/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 '@/components/livekit/alert';
+import { Alert, AlertDescription, AlertTitle } from './ui/alert';
interface ToastProps {
id: string | number;
@@ -18,7 +18,7 @@ export function toastAlert(toast: Omit) {
);
}
-export function AlertToast(props: ToastProps) {
+function AlertToast(props: ToastProps) {
const { title, description, id } = props;
return (
diff --git a/components/app.tsx b/components/app.tsx
new file mode 100644
index 00000000..5da7c44b
--- /dev/null
+++ b/components/app.tsx
@@ -0,0 +1,114 @@
+'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
deleted file mode 100644
index b390e062..00000000
--- a/components/app/app.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-'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
deleted file mode 100644
index b67d0a67..00000000
--- a/components/app/chat-transcript.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-'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
deleted file mode 100644
index 719c3813..00000000
--- a/components/app/preconnect-message.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-'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
deleted file mode 100644
index 1906f4ca..00000000
--- a/components/app/session-provider.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-'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
deleted file mode 100644
index 10f6e857..00000000
--- a/components/app/session-view.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-'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/app/tile-layout.tsx b/components/app/tile-layout.tsx
deleted file mode 100644
index 33372276..00000000
--- a/components/app/tile-layout.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-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
deleted file mode 100644
index 4519c44f..00000000
--- a/components/app/view-controller.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-'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
deleted file mode 100644
index 6356d60d..00000000
--- a/components/app/welcome-view.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-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
-
-
-
-
-
-
-
- );
-};
diff --git a/components/livekit/agent-control-bar/agent-control-bar.tsx b/components/livekit/agent-control-bar/agent-control-bar.tsx
index 1b53b1c4..6790feef 100644
--- a/components/livekit/agent-control-bar/agent-control-bar.tsx
+++ b/components/livekit/agent-control-bar/agent-control-bar.tsx
@@ -1,31 +1,26 @@
'use client';
-import { type HTMLAttributes, useCallback, useState } from 'react';
+import * as React from 'react';
+import { useCallback } from 'react';
import { Track } from 'livekit-client';
-import { useChat, useRemoteParticipants } from '@livekit/components-react';
+import { BarVisualizer, useRemoteParticipants } from '@livekit/components-react';
import { ChatTextIcon, PhoneDisconnectIcon } from '@phosphor-icons/react/dist/ssr';
-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 { 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 { cn } from '@/lib/utils';
-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;
+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;
+ onDisconnect?: () => void;
onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
}
@@ -35,137 +30,199 @@ export interface AgentControlBarProps extends UseInputControlsProps {
export function AgentControlBar({
controls,
saveUserChoices = true,
+ capabilities,
className,
+ onSendMessage,
+ onChatOpenChange,
onDisconnect,
onDeviceError,
- onChatOpenChange,
...props
-}: AgentControlBarProps & HTMLAttributes) {
- const { send } = useChat();
+}: AgentControlBarProps) {
const participants = useRemoteParticipants();
- const [chatOpen, setChatOpen] = useState(false);
- const publishPermissions = usePublishPermissions();
- const { isSessionActive, endSession } = useSession();
+ 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 {
micTrackRef,
+ visibleControls,
cameraToggle,
microphoneToggle,
screenShareToggle,
handleAudioDeviceChange,
handleVideoDeviceChange,
- handleMicrophoneDeviceSelectError,
- handleCameraDeviceSelectError,
- } = useInputControls({ onDeviceError, saveUserChoices });
+ handleDisconnect,
+ } = useAgentControlBar({
+ controls,
+ saveUserChoices,
+ });
const handleSendMessage = async (message: string) => {
- await send(message);
+ setIsSendingMessage(true);
+ try {
+ await onSendMessage?.(message);
+ } finally {
+ setIsSendingMessage(false);
+ }
};
- const handleToggleTranscript = useCallback(
- (open: boolean) => {
- setChatOpen(open);
- onChatOpenChange?.(open);
- },
- [onChatOpenChange, setChatOpen]
- );
-
- const handleDisconnect = useCallback(async () => {
- endSession();
+ const onLeave = async () => {
+ setIsDisconnecting(true);
+ await handleDisconnect();
+ setIsDisconnecting(false);
onDisconnect?.();
- }, [endSession, onDisconnect]);
-
- 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 isAgentAvailable = participants.some((p) => p.isAgent);
+ React.useEffect(() => {
+ onChatOpenChange?.(chatOpen);
+ }, [chatOpen, onChatOpenChange]);
+
+ const onMicrophoneDeviceSelectError = useCallback(
+ (error: Error) => {
+ onDeviceError?.({ source: Track.Source.Microphone, error });
+ },
+ [onDeviceError]
+ );
+ const onCameraDeviceSelectError = useCallback(
+ (error: Error) => {
+ onDeviceError?.({ source: Track.Source.Camera, error });
+ },
+ [onDeviceError]
+ );
return (
- {/* Chat Input */}
- {visibleControls.chat && (
-
+ {capabilities.supportsChatInput && (
+
)}
-
-
- {/* Toggle Microphone */}
+
+
{visibleControls.microphone && (
-
+
+
+
+
+
+
+
+
+
)}
- {/* Toggle Camera */}
- {visibleControls.camera && (
-
+ {capabilities.supportsVideoInput && visibleControls.camera && (
+
+
+
+
+
)}
- {/* Toggle Screen Share */}
- {visibleControls.screenShare && (
-
+ {capabilities.supportsScreenShare && visibleControls.screenShare && (
+
+
+
)}
- {/* Toggle Transcript */}
-
-
-
+ {visibleControls.chat && (
+
+
+
+ )}
-
- {/* Disconnect */}
{visibleControls.leave && (