diff --git a/src/components/playground/PhoneSimulator.tsx b/src/components/playground/PhoneSimulator.tsx index 6c6639c..f413721 100644 --- a/src/components/playground/PhoneSimulator.tsx +++ b/src/components/playground/PhoneSimulator.tsx @@ -12,7 +12,8 @@ import { } from "@livekit/components-react"; import { ConnectionState, Track, LocalParticipant, Room } from "livekit-client"; import { useEffect, useMemo, useState, useRef } from "react"; -import { BatteryIcon, ImageIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon, SwitchCameraIcon } from "./icons"; +import { BatteryIcon, ImageIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon, SwitchCameraIcon, VoiceIcon, CheckIcon } from "./icons"; +import { useToast } from "@/components/toast/ToasterProvider"; export interface PhoneSimulatorProps { onConnect: () => void; @@ -21,7 +22,8 @@ export interface PhoneSimulatorProps { } export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: PhoneSimulatorProps) { - const { config } = useConfig(); + const { config, setUserSettings } = useConfig(); + const { setToastMessage } = useToast(); const room = useRoomContext(); const roomState = useConnectionState(); const { localParticipant } = useLocalParticipant(); @@ -31,12 +33,22 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P const phoneContainerRef = useRef(null); const visualizerRef = useRef(null); const [showCameraMenu, setShowCameraMenu] = useState(false); + const [showVoiceMenu, setShowVoiceMenu] = useState(false); const [cameras, setCameras] = useState([]); const [processingImage, setProcessingImage] = useState(null); + const [currentVoiceId, setCurrentVoiceId] = useState("BV001_streaming"); // Default voice ID const [isCapturing, setIsCapturing] = useState(false); const [processingSource, setProcessingSource] = useState< "camera" | "upload" | null >(null); + const [lastVoiceChangeAt, setLastVoiceChangeAt] = useState(null); + + useEffect(() => { + const voiceAttr = config.settings.attributes?.find(a => a.key === "voice"); + if (voiceAttr) { + setCurrentVoiceId(voiceAttr.value); + } + }, [config.settings.attributes]); const [currentTime, setCurrentTime] = useState(""); @@ -104,14 +116,17 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P if (showCameraMenu) { setShowCameraMenu(false); } + if (showVoiceMenu) { + setShowVoiceMenu(false); + } }; - if (showCameraMenu) { + if (showCameraMenu || showVoiceMenu) { document.addEventListener("click", handleClickOutside); } return () => { document.removeEventListener("click", handleClickOutside); }; - }, [showCameraMenu]); + }, [showCameraMenu, showVoiceMenu]); useEffect(() => { if (voiceAssistant.state === "speaking") { @@ -299,6 +314,29 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P setShowCameraMenu(false); }; + const handleChangeVoice = (voiceId: string) => { + const newSettings = { ...config.settings }; + const attributes = newSettings.attributes ? [...newSettings.attributes] : []; + const voiceAttrIndex = attributes.findIndex(a => a.key === "voice"); + + if (voiceAttrIndex >= 0) { + attributes[voiceAttrIndex] = { ...attributes[voiceAttrIndex], value: voiceId }; + } else { + attributes.push({ id: "voice", key: "voice", value: voiceId }); + } + + newSettings.attributes = attributes; + setUserSettings(newSettings); + setCurrentVoiceId(voiceId); + setLastVoiceChangeAt(Date.now()); + setTimeout(() => setShowVoiceMenu(false), 100); + }; + + const handleVoiceMenuToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + setShowVoiceMenu(!showVoiceMenu); + }; + const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file && onCapture) { @@ -312,22 +350,84 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P } }; - const videoContent = useMemo(() => { + const videoContent = (() => { if (roomState === ConnectionState.Disconnected) { return (
- + +
+
- Call Agent - + + + {currentVoiceId === "BV001_streaming" ? "Female Voice" : "Male Voice"} + + + {showVoiceMenu && ( +
e.stopPropagation()} + > + + +
+ )} +
+ ); } @@ -348,7 +448,7 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P className="w-full h-full object-cover mirror-video" /> ); - }, [roomState, localCameraTrack, onConnect]); + })(); return (
@@ -449,55 +549,87 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P {/* Call Controls Overlay */} {roomState === ConnectionState.Connected && ( -
+
{phoneMode === "capture" ? ( -
-
- -
-
- -
-
- - {showCameraMenu && ( -
- {cameras.length === 0 ? ( -
- No cameras found +
+ {/* Camera Controls Row */} +
+ {/* Left: Upload */} + + + {/* Center: Capture */} + + + {/* Right: Switch Camera */} +
+ + {showCameraMenu && ( +
+ {cameras.length === 0 ? ( +
+ No cameras found +
+ ) : ( + cameras.map((device) => ( + + )) + )}
- ) : ( - cameras.map((device) => ( - - )) )}
- )} +
+ + {/* Call Controls Row */} +
+ {/* Mic Toggle */} + + + {/* End Call */} +
) : ( +
+
)}
)} diff --git a/src/components/playground/Playground.tsx b/src/components/playground/Playground.tsx index b05d4e3..a10fedb 100644 --- a/src/components/playground/Playground.tsx +++ b/src/components/playground/Playground.tsx @@ -26,6 +26,7 @@ import { useVoiceAssistant, useRoomContext, useParticipantAttributes, + useChat, } from "@livekit/components-react"; import { ConnectionState, LocalParticipant, Track, RpcError, RpcInvocationData } from "livekit-client"; import { QRCodeSVG } from "qrcode.react"; @@ -57,6 +58,7 @@ export default function Playground({ const { name } = useRoomInfo(); const [transcripts, setTranscripts] = useState([]); const { localParticipant } = useLocalParticipant(); + const { send: sendChat } = useChat(); const voiceAssistant = useVoiceAssistant(); @@ -74,7 +76,7 @@ export default function Playground({ localParticipant.setCameraEnabled(config.settings.inputs.camera); localParticipant.setMicrophoneEnabled(config.settings.inputs.mic); } - }, [config, localParticipant, roomState]); + }, [config.settings.inputs.camera, config.settings.inputs.mic, localParticipant, roomState]); useEffect(() => { if (!localParticipant || roomState !== ConnectionState.Connected) { @@ -596,9 +598,10 @@ export default function Playground({ onConnect(true)} phoneMode={phoneMode} - onCapture={(content: File) => { + onCapture={async (content: File) => { if (localParticipant) { - localParticipant.sendFile(content, { topic: "image" }); + await localParticipant.sendFile(content, { topic: "image" }); + await sendChat("用户上传了照片" ); } }} /> @@ -667,9 +670,10 @@ export default function Playground({ onConnect(true)} phoneMode={phoneMode} - onCapture={(content: File) => { + onCapture={async (content: File) => { if (localParticipant) { - localParticipant.sendFile(content, { topic: "image" }); + await localParticipant.sendFile(content, { topic: "image" }); + await sendChat("用户上传了一张照片"); } }} /> diff --git a/src/components/playground/icons.tsx b/src/components/playground/icons.tsx index c7720ae..07e2a56 100644 --- a/src/components/playground/icons.tsx +++ b/src/components/playground/icons.tsx @@ -188,3 +188,22 @@ export const SwitchCameraIcon = ({ className }: { className?: string }) => ( ); + +export const VoiceIcon = ({ className }: { className?: string }) => ( + + + + + +);