Skip to content

Commit 5ed8036

Browse files
design: avatar polish
1 parent 07a69e9 commit 5ed8036

File tree

12 files changed

+438
-260
lines changed

12 files changed

+438
-260
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+
}

components/embed-popup/agent-client.tsx

Lines changed: 39 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,39 @@
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';
1312
import { cn } from '@/lib/utils';
1413

14+
const PopupViewMotion = motion.create(PopupView);
15+
1516
export type EmbedFixedAgentClientProps = {
1617
appConfig: AppConfig;
1718
};
1819

1920
function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
21+
const isAnimating = useRef(false);
2022
const room = useMemo(() => new Room(), []);
2123
const [popupOpen, setPopupOpen] = useState(false);
2224
const [currentError, setCurrentError] = useState<EmbedErrorDetails | null>(null);
2325
const { connectionDetails, refreshConnectionDetails } = useConnectionDetails();
2426

2527
const handleTogglePopup = () => {
28+
if (isAnimating.current) {
29+
// prevent re-opening before room has disconnected
30+
return;
31+
}
32+
2633
setPopupOpen((open) => !open);
2734

2835
if (currentError) {
29-
handleDismissError();
36+
setCurrentError(null);
3037
}
3138
};
3239

@@ -35,6 +42,17 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
3542
setCurrentError(null);
3643
};
3744

45+
const handlePanelAnimationStart = () => {
46+
isAnimating.current = true;
47+
};
48+
49+
const handlePanelAnimationComplete = () => {
50+
isAnimating.current = false;
51+
if (!popupOpen && room.state !== 'disconnected') {
52+
room.disconnect();
53+
}
54+
};
55+
3856
useEffect(() => {
3957
const onDisconnected = () => {
4058
setPopupOpen(false);
@@ -81,11 +99,8 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
8199
}
82100
}
83101
};
84-
connect();
85102

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

91106
return (
@@ -107,53 +122,29 @@ function EmbedFixedAgentClient({ appConfig }: EmbedFixedAgentClientProps) {
107122
}}
108123
transition={{
109124
type: 'spring',
110-
duration: 1,
111125
bounce: 0,
126+
duration: 1,
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
134+
<ErrorMessage error={currentError} handleDismissError={handleDismissError} />
135+
<PopupViewMotion
145136
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>
137+
initial={{ opacity: 1 }}
138+
animate={{ opacity: currentError === null ? 1 : 0 }}
139+
transition={{
140+
type: 'linear',
141+
duration: 200,
142+
}}
143+
disabled={!popupOpen}
144+
sessionStarted={popupOpen}
145+
onDisplayError={setCurrentError}
146+
className={cn('absolute inset-0')}
147+
/>
157148
</div>
158149
</div>
159150
</motion.div>
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 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)