|
1 |
| -import { MicrophoneToggle } from '@/components/embed-popup/microphone-toggle'; |
| 1 | +'use client'; |
| 2 | + |
| 3 | +import * as React from 'react'; |
| 4 | +import { useCallback } from 'react'; |
| 5 | +import { Track } from 'livekit-client'; |
| 6 | +import { BarVisualizer, useRemoteParticipants } from '@livekit/components-react'; |
| 7 | +import { ChatTextIcon } from '@phosphor-icons/react/dist/ssr'; |
2 | 8 | import { ChatInput } from '@/components/livekit/chat/chat-input';
|
3 |
| -import { useAgentControlBar } from '@/hooks/use-agent-control-bar'; |
| 9 | +import { DeviceSelect } from '@/components/livekit/device-select'; |
| 10 | +import { TrackToggle } from '@/components/livekit/track-toggle'; |
| 11 | +import { Toggle } from '@/components/ui/toggle'; |
| 12 | +import { UseAgentControlBarProps, useAgentControlBar } from '@/hooks/use-agent-control-bar'; |
| 13 | +import { AppConfig } from '@/lib/types'; |
| 14 | +import { cn } from '@/lib/utils'; |
4 | 15 |
|
5 |
| -interface ActionBarProps { |
6 |
| - onSend: (message: string) => void; |
| 16 | +export interface AgentControlBarProps |
| 17 | + extends React.HTMLAttributes<HTMLDivElement>, |
| 18 | + UseAgentControlBarProps { |
| 19 | + capabilities: Pick<AppConfig, 'supportsChatInput' | 'supportsVideoInput' | 'supportsScreenShare'>; |
| 20 | + onChatOpenChange?: (open: boolean) => void; |
| 21 | + onSendMessage?: (message: string) => Promise<void>; |
| 22 | + onDeviceError?: (error: { source: Track.Source; error: Error }) => void; |
7 | 23 | }
|
8 | 24 |
|
9 |
| -export function ActionBar({ onSend }: ActionBarProps) { |
| 25 | +/** |
| 26 | + * A control bar specifically designed for voice assistant interfaces |
| 27 | + */ |
| 28 | +export function ActionBar({ |
| 29 | + controls, |
| 30 | + saveUserChoices = true, |
| 31 | + capabilities, |
| 32 | + className, |
| 33 | + onSendMessage, |
| 34 | + onChatOpenChange, |
| 35 | + onDeviceError, |
| 36 | + ...props |
| 37 | +}: AgentControlBarProps) { |
| 38 | + const participants = useRemoteParticipants(); |
| 39 | + const [chatOpen, setChatOpen] = React.useState(false); |
| 40 | + const [isSendingMessage, setIsSendingMessage] = React.useState(false); |
| 41 | + |
| 42 | + const isAgentAvailable = participants.some((p) => p.isAgent); |
| 43 | + const isInputDisabled = !chatOpen || !isAgentAvailable || isSendingMessage; |
| 44 | + |
10 | 45 | const {
|
11 | 46 | micTrackRef,
|
12 |
| - // FIXME: how do I explicitly ensure only the microphone channel is used? |
13 | 47 | visibleControls,
|
| 48 | + cameraToggle, |
14 | 49 | microphoneToggle,
|
| 50 | + screenShareToggle, |
15 | 51 | handleAudioDeviceChange,
|
| 52 | + handleVideoDeviceChange, |
16 | 53 | } = useAgentControlBar({
|
17 |
| - controls: { microphone: true }, |
18 |
| - saveUserChoices: true, |
| 54 | + controls, |
| 55 | + saveUserChoices, |
19 | 56 | });
|
20 | 57 |
|
| 58 | + const handleSendMessage = async (message: string) => { |
| 59 | + setIsSendingMessage(true); |
| 60 | + try { |
| 61 | + await onSendMessage?.(message); |
| 62 | + } finally { |
| 63 | + setIsSendingMessage(false); |
| 64 | + } |
| 65 | + }; |
| 66 | + |
| 67 | + React.useEffect(() => { |
| 68 | + onChatOpenChange?.(chatOpen); |
| 69 | + }, [chatOpen, onChatOpenChange]); |
| 70 | + |
| 71 | + const onMicrophoneDeviceSelectError = useCallback( |
| 72 | + (error: Error) => { |
| 73 | + onDeviceError?.({ source: Track.Source.Microphone, error }); |
| 74 | + }, |
| 75 | + [onDeviceError] |
| 76 | + ); |
| 77 | + const onCameraDeviceSelectError = useCallback( |
| 78 | + (error: Error) => { |
| 79 | + onDeviceError?.({ source: Track.Source.Camera, error }); |
| 80 | + }, |
| 81 | + [onDeviceError] |
| 82 | + ); |
| 83 | + |
21 | 84 | return (
|
22 | 85 | <div
|
23 | 86 | 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" |
| 87 | + className={cn( |
| 88 | + 'bg-background border-separator1 dark:border-separator1 relative z-20 mx-2 mb-1 flex flex-col rounded-[24px] border p-1 drop-shadow-md', |
| 89 | + className |
| 90 | + )} |
| 91 | + {...props} |
25 | 92 | >
|
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> |
| 93 | + {capabilities.supportsChatInput && ( |
| 94 | + <div |
| 95 | + inert={!chatOpen} |
| 96 | + className={cn( |
| 97 | + 'relative overflow-hidden transition-[height] duration-300 ease-out', |
| 98 | + chatOpen ? 'h-[46px]' : 'h-0' |
| 99 | + )} |
| 100 | + > |
| 101 | + <div |
| 102 | + className={cn( |
| 103 | + 'absolute inset-x-0 top-0 flex h-9 w-full transition-opacity duration-150 ease-linear', |
| 104 | + chatOpen ? 'opacity-100 delay-150' : 'opacity-0' |
| 105 | + )} |
| 106 | + > |
| 107 | + <ChatInput onSend={handleSendMessage} disabled={isInputDisabled} className="w-full" /> |
| 108 | + </div> |
| 109 | + <hr className="border-bg2 absolute inset-x-0 bottom-0 my-1 w-full" /> |
| 110 | + </div> |
| 111 | + )} |
36 | 112 |
|
37 |
| - <ChatInput className="w-0 shrink-1 grow-1" onSend={onSend} /> |
| 113 | + <div className="flex flex-row justify-between gap-1"> |
| 114 | + <div className="flex gap-1"> |
| 115 | + {visibleControls.microphone && ( |
| 116 | + <div className="flex items-center gap-0"> |
| 117 | + <TrackToggle |
| 118 | + variant="primary" |
| 119 | + source={Track.Source.Microphone} |
| 120 | + pressed={microphoneToggle.enabled} |
| 121 | + disabled={microphoneToggle.pending} |
| 122 | + onPressedChange={microphoneToggle.toggle} |
| 123 | + className="peer/track group/track relative w-auto pr-3 pl-3 has-[+_*]:rounded-r-none has-[+_*]:border-r-0 has-[+_*]:pr-2" |
| 124 | + > |
| 125 | + <BarVisualizer |
| 126 | + barCount={3} |
| 127 | + trackRef={micTrackRef} |
| 128 | + options={{ minHeight: 5 }} |
| 129 | + className="flex h-full w-auto items-center justify-center gap-0.5" |
| 130 | + > |
| 131 | + <span |
| 132 | + className={cn([ |
| 133 | + 'h-full w-0.5 origin-center rounded-2xl', |
| 134 | + 'group-data-[state=on]/track:bg-fg1 group-data-[state=off]/track:bg-destructive-foreground', |
| 135 | + 'data-lk-muted:bg-muted', |
| 136 | + ])} |
| 137 | + ></span> |
| 138 | + </BarVisualizer> |
| 139 | + </TrackToggle> |
| 140 | + <DeviceSelect |
| 141 | + size="sm" |
| 142 | + kind="audioinput" |
| 143 | + requestPermissions={false} |
| 144 | + onMediaDeviceError={onMicrophoneDeviceSelectError} |
| 145 | + onActiveDeviceChange={handleAudioDeviceChange} |
| 146 | + className={cn([ |
| 147 | + 'pl-2', |
| 148 | + 'peer-data-[state=off]/track:text-destructive-foreground', |
| 149 | + 'hover:text-fg1 focus:text-fg1', |
| 150 | + 'hover:peer-data-[state=off]/track:text-destructive-foreground focus:peer-data-[state=off]/track:text-destructive-foreground', |
| 151 | + 'hidden rounded-l-none md:block', |
| 152 | + ])} |
| 153 | + /> |
| 154 | + </div> |
| 155 | + )} |
| 156 | + |
| 157 | + {capabilities.supportsVideoInput && visibleControls.camera && ( |
| 158 | + <div className="flex items-center gap-0"> |
| 159 | + <TrackToggle |
| 160 | + variant="primary" |
| 161 | + source={Track.Source.Camera} |
| 162 | + pressed={cameraToggle.enabled} |
| 163 | + pending={cameraToggle.pending} |
| 164 | + disabled={cameraToggle.pending} |
| 165 | + onPressedChange={cameraToggle.toggle} |
| 166 | + className="peer/track relative w-auto pr-3 pl-3 disabled:opacity-100 has-[+_*]:rounded-r-none has-[+_*]:border-r-0 has-[+_*]:pr-2" |
| 167 | + /> |
| 168 | + <DeviceSelect |
| 169 | + size="sm" |
| 170 | + kind="videoinput" |
| 171 | + requestPermissions={false} |
| 172 | + onMediaDeviceError={onCameraDeviceSelectError} |
| 173 | + onActiveDeviceChange={handleVideoDeviceChange} |
| 174 | + className={cn([ |
| 175 | + 'pl-2', |
| 176 | + 'peer-data-[state=off]/track:text-destructive-foreground', |
| 177 | + 'hover:text-fg1 focus:text-fg1', |
| 178 | + 'hover:peer-data-[state=off]/track:text-destructive-foreground focus:peer-data-[state=off]/track:text-destructive-foreground', |
| 179 | + 'rounded-l-none', |
| 180 | + ])} |
| 181 | + /> |
| 182 | + </div> |
| 183 | + )} |
| 184 | + </div> |
| 185 | + <div className="flex gap-1"> |
| 186 | + {capabilities.supportsScreenShare && visibleControls.screenShare && ( |
| 187 | + <div className="flex items-center gap-0"> |
| 188 | + <TrackToggle |
| 189 | + variant="secondary" |
| 190 | + source={Track.Source.ScreenShare} |
| 191 | + pressed={screenShareToggle.enabled} |
| 192 | + disabled={screenShareToggle.pending} |
| 193 | + onPressedChange={screenShareToggle.toggle} |
| 194 | + className="relative w-auto" |
| 195 | + /> |
| 196 | + </div> |
| 197 | + )} |
| 198 | + |
| 199 | + {visibleControls.chat && ( |
| 200 | + <Toggle |
| 201 | + variant="secondary" |
| 202 | + aria-label="Toggle chat" |
| 203 | + pressed={chatOpen} |
| 204 | + onPressedChange={setChatOpen} |
| 205 | + disabled={!isAgentAvailable} |
| 206 | + className="aspect-square h-full" |
| 207 | + > |
| 208 | + <ChatTextIcon weight="bold" /> |
| 209 | + </Toggle> |
| 210 | + )} |
| 211 | + </div> |
| 212 | + </div> |
38 | 213 | </div>
|
39 | 214 | );
|
40 | 215 | }
|
0 commit comments