Skip to content

Commit 438f2d6

Browse files
design: avatar polish
1 parent 07a69e9 commit 438f2d6

File tree

12 files changed

+450
-276
lines changed

12 files changed

+450
-276
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: 47 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,55 @@
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 = () => {
27+
if (isAnimating.current) {
28+
// prevent re-opening before room has disconnected
29+
return;
30+
}
31+
2632
setPopupOpen((open) => !open);
2733

28-
if (currentError) {
29-
handleDismissError();
34+
if (error) {
35+
setError(null);
3036
}
3137
};
3238

3339
const handleDismissError = () => {
3440
room.disconnect();
35-
setCurrentError(null);
41+
setError(null);
42+
};
43+
44+
const handlePanelAnimationStart = () => {
45+
isAnimating.current = true;
46+
};
47+
48+
const handlePanelAnimationComplete = () => {
49+
isAnimating.current = false;
50+
if (!popupOpen && room.state !== 'disconnected') {
51+
room.disconnect();
52+
}
3653
};
3754

3855
useEffect(() => {
@@ -41,7 +58,7 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
4158
refreshConnectionDetails();
4259
};
4360
const onMediaDevicesError = (error: Error) => {
44-
setCurrentError({
61+
setError({
4562
title: 'Encountered an error with your media devices',
4663
description: `${error.name}: ${error.message}`,
4764
});
@@ -74,26 +91,23 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
7491
} catch (error: unknown) {
7592
if (error instanceof Error) {
7693
console.error('Error connecting to agent:', error);
77-
setCurrentError({
94+
setError({
7895
title: 'There was an error connecting to the agent',
7996
description: `${error.name}: ${error.message}`,
8097
});
8198
}
8299
}
83100
};
84-
connect();
85101

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

91105
return (
92106
<RoomContext.Provider value={room}>
93107
<RoomAudioRenderer />
94108
<StartAudio label="Start Audio" />
95109

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

98112
<motion.div
99113
inert={!popupOpen}
@@ -107,58 +121,33 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
107121
}}
108122
transition={{
109123
type: 'spring',
110-
duration: 1,
111124
bounce: 0,
125+
duration: popupOpen ? 1 : 0.2,
112126
}}
127+
onAnimationStart={handlePanelAnimationStart}
128+
onAnimationComplete={handlePanelAnimationComplete}
113129
className="fixed right-0 bottom-20 z-50 w-full px-4"
114130
>
115131
<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]">
116132
<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
152-
disabled={!popupOpen}
153-
sessionStarted={popupOpen}
154-
onDisplayError={setCurrentError}
155-
/>
156-
</div>
133+
<ErrorMessage error={error} handleDismissError={handleDismissError} />
134+
<PopupViewMotion
135+
initial={{ opacity: 1 }}
136+
animate={{ opacity: error === null ? 1 : 0 }}
137+
transition={{
138+
type: 'linear',
139+
duration: 0.2,
140+
}}
141+
disabled={!popupOpen}
142+
sessionStarted={popupOpen}
143+
onEmbedError={setError}
144+
className="absolute inset-0"
145+
/>
157146
</div>
158147
</div>
159148
</motion.div>
160149
</RoomContext.Provider>
161150
);
162151
}
163152

164-
export default EmbedFixedAgentClient;
153+
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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { XIcon } from '@phosphor-icons/react';
2+
import { EmbedErrorDetails } from '@/lib/types';
3+
import { cn } from '@/lib/utils';
4+
import { Button } from '../ui/button';
5+
6+
interface ErrorMessageProps {
7+
error: EmbedErrorDetails | null;
8+
handleDismissError: () => void;
9+
}
10+
11+
export function ErrorMessage({ error, handleDismissError }: ErrorMessageProps) {
12+
return (
13+
<div
14+
inert={error === null}
15+
className={cn(
16+
'absolute inset-0 z-50 flex h-full w-full flex-col items-center justify-center gap-5 transition-opacity',
17+
error === null ? 'opacity-0' : 'opacity-100'
18+
)}
19+
>
20+
<div className="pl-3">
21+
{/* eslint-disable-next-line @next/next/no-img-element */}
22+
<img src="/lk-logo.svg" alt="LiveKit Logo" className="block size-6 dark:hidden" />
23+
{/* eslint-disable-next-line @next/next/no-img-element */}
24+
<img src="/lk-logo-dark.svg" alt="LiveKit Logo" className="hidden size-6 dark:block" />
25+
</div>
26+
27+
<div className="flex w-full flex-col justify-center gap-1 overflow-auto px-4 text-center">
28+
<span className="text-sm font-medium">{error?.title}</span>
29+
<span className="text-xs">{error?.description}</span>
30+
</div>
31+
32+
<Button variant="secondary" onClick={handleDismissError}>
33+
<XIcon /> Dismiss
34+
</Button>
35+
</div>
36+
);
37+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Track } from 'livekit-client';
2+
import {
3+
BarVisualizer,
4+
type TrackReferenceOrPlaceholder,
5+
useTrackToggle,
6+
} from '@livekit/components-react';
7+
import { DeviceSelect } from '@/components/livekit/device-select';
8+
import { TrackToggle } from '@/components/livekit/track-toggle';
9+
import { cn } from '@/lib/utils';
10+
11+
interface MicrophoneToggleProps {
12+
micTrackRef: TrackReferenceOrPlaceholder;
13+
microphoneToggle: ReturnType<typeof useTrackToggle<Track.Source.Microphone>>;
14+
handleAudioDeviceChange: (deviceId: string) => void;
15+
}
16+
17+
export function MicrophoneToggle({
18+
microphoneToggle,
19+
micTrackRef,
20+
handleAudioDeviceChange,
21+
}: MicrophoneToggleProps) {
22+
return (
23+
<div className="flex items-center gap-0">
24+
<TrackToggle
25+
variant="primary"
26+
source={Track.Source.Microphone}
27+
pressed={microphoneToggle.enabled}
28+
disabled={microphoneToggle.pending}
29+
onPressedChange={microphoneToggle.toggle}
30+
className="peer/track group/track relative w-auto pr-3 pl-3 md:rounded-r-none md:border-r-0 md:pr-2"
31+
>
32+
<BarVisualizer
33+
barCount={3}
34+
trackRef={micTrackRef}
35+
options={{ minHeight: 5 }}
36+
className="flex h-full w-auto items-center justify-center gap-0.5"
37+
>
38+
<span
39+
className={cn([
40+
'h-full w-0.5 origin-center rounded-2xl',
41+
'group-data-[state=on]/track:bg-fg1 group-data-[state=off]/track:bg-destructive-foreground',
42+
'data-lk-muted:bg-muted',
43+
])}
44+
></span>
45+
</BarVisualizer>
46+
</TrackToggle>
47+
48+
<hr className="bg-separator1 peer-data-[state=off]/track:bg-separatorSerious relative z-10 -mr-px hidden h-4 w-px md:block" />
49+
50+
<DeviceSelect
51+
size="sm"
52+
kind="audioinput"
53+
onActiveDeviceChange={handleAudioDeviceChange}
54+
className={cn([
55+
'pl-2',
56+
'peer-data-[state=off]/track:text-destructive-foreground',
57+
'hover:text-fg1 focus:text-fg1',
58+
'hover:peer-data-[state=off]/track:text-destructive-foreground focus:peer-data-[state=off]/track:text-destructive-foreground',
59+
'hidden rounded-l-none md:block',
60+
])}
61+
/>
62+
</div>
63+
);
64+
}

0 commit comments

Comments
 (0)