Skip to content

Commit e5cf42f

Browse files
design: avatar polish
1 parent 07a69e9 commit e5cf42f

File tree

13 files changed

+477
-288
lines changed

13 files changed

+477
-288
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { MicrophoneToggle } from '@/components/embed-popup/microphone-toggle';
2+
import { ChatInput } from '@/components/livekit/chat/chat-input';
3+
import { useAgentControlBar } from '@/hooks/use-agent-control-bar';
4+
5+
interface ActionBarProps {
6+
onSend: (message: string) => void;
7+
}
8+
9+
export function ActionBar({ onSend }: ActionBarProps) {
10+
const {
11+
micTrackRef,
12+
// FIXME: how do I explicitly ensure only the microphone channel is used?
13+
visibleControls,
14+
microphoneToggle,
15+
handleAudioDeviceChange,
16+
} = useAgentControlBar({
17+
controls: { microphone: true },
18+
saveUserChoices: true,
19+
});
20+
21+
return (
22+
<div
23+
aria-label="Voice assistant controls"
24+
className="bg-bg1 border-separator1 relative z-20 mx-1 flex h-12 shrink-0 grow-0 items-center gap-1 rounded-full border px-1 drop-shadow-md"
25+
>
26+
<div className="flex gap-1">
27+
{visibleControls.microphone && (
28+
<MicrophoneToggle
29+
micTrackRef={micTrackRef}
30+
microphoneToggle={microphoneToggle}
31+
handleAudioDeviceChange={handleAudioDeviceChange}
32+
/>
33+
)}
34+
{/* FIXME: do I need to handle the other channels here? */}
35+
</div>
36+
37+
<ChatInput className="w-0 shrink-1 grow-1" onSend={onSend} />
38+
</div>
39+
);
40+
}
Lines changed: 51 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,52 @@
11
'use client';
22

3-
import { useEffect, useMemo, useState } from 'react';
3+
import { useEffect, useMemo, useRef, useState } from 'react';
44
import { Room, RoomEvent } from 'livekit-client';
55
import { motion } from 'motion/react';
66
import { RoomAudioRenderer, RoomContext, StartAudio } from '@livekit/components-react';
7-
import { XIcon } from '@phosphor-icons/react';
7+
import { ErrorMessage } from '@/components/embed-popup/error-message';
88
import { PopupView } from '@/components/embed-popup/popup-view';
99
import { Trigger } from '@/components/embed-popup/trigger';
10-
import { Button } from '@/components/ui/button';
1110
import useConnectionDetails from '@/hooks/use-connection-details';
1211
import { type AppConfig, EmbedErrorDetails } from '@/lib/types';
13-
import { cn } from '@/lib/utils';
12+
13+
const PopupViewMotion = motion.create(PopupView);
1414

1515
export type EmbedFixedAgentClientProps = {
1616
appConfig: AppConfig;
1717
};
1818

19-
function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
19+
function AgentClient({ appConfig }: EmbedFixedAgentClientProps) {
20+
const isAnimating = useRef(false);
2021
const room = useMemo(() => new Room(), []);
2122
const [popupOpen, setPopupOpen] = useState(false);
22-
const [currentError, setCurrentError] = useState<EmbedErrorDetails | null>(null);
23+
const [error, setError] = useState<EmbedErrorDetails | null>(null);
2324
const { connectionDetails, refreshConnectionDetails } = useConnectionDetails();
2425

2526
const handleTogglePopup = () => {
26-
setPopupOpen((open) => !open);
27-
28-
if (currentError) {
29-
handleDismissError();
27+
if (isAnimating.current) {
28+
// prevent re-opening before room has disconnected
29+
return;
3030
}
31+
32+
setError(null);
33+
setPopupOpen((open) => !open);
3134
};
3235

3336
const handleDismissError = () => {
3437
room.disconnect();
35-
setCurrentError(null);
38+
setError(null);
39+
};
40+
41+
const handlePanelAnimationStart = () => {
42+
isAnimating.current = true;
43+
};
44+
45+
const handlePanelAnimationComplete = () => {
46+
isAnimating.current = false;
47+
if (!popupOpen && room.state !== 'disconnected') {
48+
room.disconnect();
49+
}
3650
};
3751

3852
useEffect(() => {
@@ -41,7 +55,7 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
4155
refreshConnectionDetails();
4256
};
4357
const onMediaDevicesError = (error: Error) => {
44-
setCurrentError({
58+
setError({
4559
title: 'Encountered an error with your media devices',
4660
description: `${error.name}: ${error.message}`,
4761
});
@@ -58,10 +72,14 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
5872
if (!popupOpen) {
5973
return;
6074
}
61-
if (room.state !== 'disconnected') {
75+
if (!connectionDetails) {
76+
setError({
77+
title: 'Error fetching connection details',
78+
description: 'Please try again later',
79+
});
6280
return;
6381
}
64-
if (!connectionDetails) {
82+
if (room.state !== 'disconnected') {
6583
return;
6684
}
6785

@@ -74,26 +92,23 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
7492
} catch (error: unknown) {
7593
if (error instanceof Error) {
7694
console.error('Error connecting to agent:', error);
77-
setCurrentError({
95+
setError({
7896
title: 'There was an error connecting to the agent',
7997
description: `${error.name}: ${error.message}`,
8098
});
8199
}
82100
}
83101
};
84-
connect();
85102

86-
return () => {
87-
room.disconnect();
88-
};
103+
connect();
89104
}, [room, popupOpen, connectionDetails, appConfig.isPreConnectBufferEnabled]);
90105

91106
return (
92107
<RoomContext.Provider value={room}>
93108
<RoomAudioRenderer />
94109
<StartAudio label="Start Audio" />
95110

96-
<Trigger error={!!currentError} popupOpen={popupOpen} onToggle={handleTogglePopup} />
111+
<Trigger error={error} popupOpen={popupOpen} onToggle={handleTogglePopup} />
97112

98113
<motion.div
99114
inert={!popupOpen}
@@ -107,58 +122,35 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
107122
}}
108123
transition={{
109124
type: 'spring',
110-
duration: 1,
111125
bounce: 0,
126+
duration: popupOpen ? 1 : 0.2,
112127
}}
128+
onAnimationStart={handlePanelAnimationStart}
129+
onAnimationComplete={handlePanelAnimationComplete}
113130
className="fixed right-0 bottom-20 z-50 w-full px-4"
114131
>
115132
<div className="bg-bg2 dark:bg-bg1 border-separator1 ml-auto h-[480px] w-full rounded-[28px] border drop-shadow-md md:max-w-[360px]">
116133
<div className="relative h-full w-full">
117-
<div
118-
inert={currentError === null}
119-
className={cn(
120-
'absolute inset-0 flex h-full w-full flex-col items-center justify-center gap-5 transition-opacity',
121-
currentError === null ? 'opacity-0' : 'opacity-100'
122-
)}
123-
>
124-
<div className="pl-3">
125-
{/* eslint-disable-next-line @next/next/no-img-element */}
126-
<img src="/lk-logo.svg" alt="LiveKit Logo" className="block size-6 dark:hidden" />
127-
{/* eslint-disable-next-line @next/next/no-img-element */}
128-
<img
129-
src="/lk-logo-dark.svg"
130-
alt="LiveKit Logo"
131-
className="hidden size-6 dark:block"
132-
/>
133-
</div>
134-
135-
<div className="flex w-full flex-col justify-center gap-1 overflow-auto px-4 text-center">
136-
<span className="text-sm font-medium">{currentError?.title}</span>
137-
<span className="text-xs">{currentError?.description}</span>
138-
</div>
139-
140-
<Button variant="secondary" onClick={handleDismissError}>
141-
<XIcon /> Dismiss
142-
</Button>
143-
</div>
144-
<div
145-
inert={currentError !== null}
146-
className={cn(
147-
'absolute inset-0 transition-opacity',
148-
currentError === null ? 'opacity-100' : 'opacity-0'
149-
)}
150-
>
151-
<PopupView
134+
<ErrorMessage error={error} />
135+
{!error && (
136+
<PopupViewMotion
137+
initial={{ opacity: 1 }}
138+
animate={{ opacity: error === null ? 1 : 0 }}
139+
transition={{
140+
type: 'linear',
141+
duration: 0.2,
142+
}}
152143
disabled={!popupOpen}
153144
sessionStarted={popupOpen}
154-
onDisplayError={setCurrentError}
145+
onEmbedError={setError}
146+
className="absolute inset-0"
155147
/>
156-
</div>
148+
)}
157149
</div>
158150
</div>
159151
</motion.div>
160152
</RoomContext.Provider>
161153
);
162154
}
163155

164-
export default EmbedFixedAgentClient;
156+
export default AgentClient;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { AnimatePresence, motion } from 'motion/react';
2+
import { type AgentState, BarVisualizer, type TrackReference } from '@livekit/components-react';
3+
import { useDelayedValue } from '@/hooks/useDelayedValue';
4+
import { cn } from '@/lib/utils';
5+
6+
const TILE_TRANSITION = {
7+
type: 'spring',
8+
stiffness: 675,
9+
damping: 75,
10+
mass: 1,
11+
};
12+
13+
interface AudioVisualizerProps {
14+
agentState: AgentState;
15+
audioTrack?: TrackReference;
16+
videoTrack?: TrackReference;
17+
}
18+
19+
export function AudioVisualizer({ agentState, audioTrack, videoTrack }: AudioVisualizerProps) {
20+
// wait for the possible video track
21+
// FIXME: pass IO expectations upfront to avoid this delay
22+
const isAgentConnected = useDelayedValue(
23+
agentState !== 'disconnected' && agentState !== 'connecting' && agentState !== 'initializing',
24+
500
25+
);
26+
27+
return (
28+
<AnimatePresence>
29+
{!videoTrack && (
30+
<motion.div
31+
key="audio-visualizer"
32+
className={cn(
33+
'bg-bg2 dark:bg-bg1 pointer-events-none absolute z-10 flex aspect-[1.5] w-64 items-center justify-center rounded-2xl border border-transparent transition-colors',
34+
isAgentConnected && 'bg-bg1 border-separator1 drop-shadow-2xl'
35+
)}
36+
initial={{
37+
scale: 1,
38+
left: '50%',
39+
top: '50%',
40+
translateX: '-50%',
41+
translateY: '-50%',
42+
transformOrigin: 'center top',
43+
}}
44+
animate={{
45+
scale: isAgentConnected ? 0.4 : 1,
46+
top: isAgentConnected ? '12px' : '50%',
47+
translateY: isAgentConnected ? '0' : '-50%',
48+
}}
49+
transition={{ TILE_TRANSITION }}
50+
>
51+
<BarVisualizer
52+
barCount={5}
53+
state={agentState}
54+
trackRef={audioTrack}
55+
options={{ minHeight: 5 }}
56+
className="flex h-full w-auto items-center justify-center gap-3"
57+
>
58+
<span
59+
className={cn([
60+
'bg-muted min-h-6 w-6 rounded-full',
61+
'origin-center transition-colors duration-250 ease-linear',
62+
'data-[lk-highlighted=true]:bg-foreground data-[lk-muted=true]:bg-muted',
63+
])}
64+
/>
65+
</BarVisualizer>
66+
</motion.div>
67+
)}
68+
</AnimatePresence>
69+
);
70+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { EmbedErrorDetails } from '@/lib/types';
2+
import { cn } from '@/lib/utils';
3+
4+
interface ErrorMessageProps {
5+
error: EmbedErrorDetails | null;
6+
}
7+
8+
export function ErrorMessage({ error }: ErrorMessageProps) {
9+
return (
10+
<div
11+
inert={error === null}
12+
className={cn(
13+
'absolute inset-0 z-50 flex h-full w-full flex-col items-center justify-center gap-5 transition-opacity',
14+
error === null ? 'opacity-0' : 'opacity-100'
15+
)}
16+
>
17+
<div className="pl-3">
18+
{/* eslint-disable-next-line @next/next/no-img-element */}
19+
<img src="/lk-logo.svg" alt="LiveKit Logo" className="block size-6 dark:hidden" />
20+
{/* eslint-disable-next-line @next/next/no-img-element */}
21+
<img src="/lk-logo-dark.svg" alt="LiveKit Logo" className="hidden size-6 dark:block" />
22+
</div>
23+
24+
<div className="flex w-full flex-col justify-center gap-4 overflow-auto px-4 text-center">
25+
<span className="mx-4 leading-tight font-medium text-pretty">{error?.title}</span>
26+
<span className="text-sm">{error?.description}</span>
27+
</div>
28+
</div>
29+
);
30+
}

0 commit comments

Comments
 (0)