add phone simulator

This commit is contained in:
2025-12-04 14:45:19 +08:00
parent 137dd28877
commit 2ca304d64b
3 changed files with 298 additions and 35 deletions

View File

@@ -0,0 +1,173 @@
"use client";
import { useConfig } from "@/hooks/useConfig";
import {
BarVisualizer,
useConnectionState,
useLocalParticipant,
useRoomContext,
useTracks,
useVoiceAssistant,
VideoTrack,
} from "@livekit/components-react";
import { ConnectionState, Track, LocalParticipant } from "livekit-client";
import { useEffect, useMemo, useState } from "react";
import { BatteryIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon } from "./icons";
export interface PhoneSimulatorProps {
onConnect: () => void;
}
export function PhoneSimulator({ onConnect }: PhoneSimulatorProps) {
const { config } = useConfig();
const room = useRoomContext();
const roomState = useConnectionState();
const { localParticipant } = useLocalParticipant();
const tracks = useTracks();
const voiceAssistant = useVoiceAssistant();
const [currentTime, setCurrentTime] = useState("");
useEffect(() => {
const updateTime = () => {
const now = new Date();
setCurrentTime(
now.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
);
};
updateTime();
const interval = setInterval(updateTime, 60000);
return () => clearInterval(interval);
}, []);
const localTracks = tracks.filter(
({ participant }) => participant instanceof LocalParticipant
);
const localCameraTrack = localTracks.find(
({ source }) => source === Track.Source.Camera
);
const isMicEnabled = localParticipant.isMicrophoneEnabled;
const handleMicToggle = async () => {
if (isMicEnabled) {
await localParticipant.setMicrophoneEnabled(false);
} else {
await localParticipant.setMicrophoneEnabled(true);
}
};
const handleDisconnect = () => {
room.disconnect();
};
const videoContent = useMemo(() => {
if (roomState === ConnectionState.Disconnected) {
return (
<div className="flex items-center justify-center h-full w-full bg-gray-900 text-gray-500 text-sm p-4 text-center">
<button
onClick={onConnect}
className="flex flex-col items-center gap-4 hover:opacity-80 transition-opacity"
>
<div
className="w-16 h-16 rounded-full flex items-center justify-center text-white"
style={{ backgroundColor: `var(--lk-theme-color)` }}
>
<PhoneIcon className="w-8 h-8" />
</div>
<span className="font-medium text-white">Call Agent</span>
</button>
</div>
);
}
if (!localCameraTrack) {
return (
<div className="flex items-center justify-center h-full w-full bg-gray-900 text-gray-500 text-sm p-4 text-center">
<div className="flex flex-col items-center gap-2">
<span>Camera off</span>
</div>
</div>
);
}
return (
<VideoTrack
trackRef={localCameraTrack}
className="w-full h-full object-cover mirror-video"
/>
);
}, [roomState, localCameraTrack]);
return (
<div className="w-auto max-w-full h-full aspect-[9/19.5] max-h-full bg-black rounded-[40px] border-[12px] border-gray-900 overflow-hidden relative shadow-2xl flex flex-col shrink-0">
<style jsx global>{`
.mirror-video video {
transform: scaleX(-1);
}
`}</style>
{/* Status Bar */}
<div className="h-12 w-full bg-black/20 absolute top-0 left-0 z-20 flex items-center justify-between px-6 text-white text-xs font-medium backdrop-blur-sm">
<span>{currentTime}</span>
<div className="flex items-center gap-2">
<WifiIcon className="w-4 h-4" />
<BatteryIcon className="w-4 h-4" />
</div>
</div>
{/* Dynamic Island / Notch Placeholder */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-7 bg-black rounded-b-2xl z-30"></div>
{/* Main Content */}
<div className="flex-grow relative bg-gray-950 w-full h-full">
{videoContent}
{/* Agent Audio Visualizer (Top Left) */}
{roomState === ConnectionState.Connected && voiceAssistant.audioTrack && (
<div className="absolute top-14 left-4 z-20 p-2 bg-black/40 backdrop-blur-md rounded-lg border border-white/10 shadow-lg">
<div className="h-8 w-24 flex items-center justify-center [--lk-va-bar-width:3px] [--lk-va-bar-gap:2px] [--lk-fg:white]">
<BarVisualizer
state={voiceAssistant.state}
trackRef={voiceAssistant.audioTrack}
barCount={7}
options={{ minHeight: 5 }}
/>
</div>
</div>
)}
</div>
{/* Call Controls Overlay */}
{roomState === ConnectionState.Connected && (
<div className="absolute bottom-8 left-0 w-full px-8 flex items-center justify-between z-20">
<button
className={`p-4 rounded-full backdrop-blur-md transition-colors ${
!isMicEnabled
? "bg-white text-black"
: "bg-gray-600/50 text-white hover:bg-gray-600/70"
}`}
onClick={handleMicToggle}
>
{isMicEnabled ? (
<MicIcon className="w-6 h-6" />
) : (
<MicOffIcon className="w-6 h-6" />
)}
</button>
<button
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
onClick={handleDisconnect}
>
<PhoneOffIcon className="w-6 h-6" />
</button>
</div>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { ColorPicker } from "@/components/colorPicker/ColorPicker";
import { AudioInputTile } from "@/components/config/AudioInputTile"; import { AudioInputTile } from "@/components/config/AudioInputTile";
import { ConfigurationPanelItem } from "@/components/config/ConfigurationPanelItem"; import { ConfigurationPanelItem } from "@/components/config/ConfigurationPanelItem";
import { NameValueRow } from "@/components/config/NameValueRow"; import { NameValueRow } from "@/components/config/NameValueRow";
import { PhoneSimulator } from "@/components/playground/PhoneSimulator";
import { PlaygroundHeader } from "@/components/playground/PlaygroundHeader"; import { PlaygroundHeader } from "@/components/playground/PlaygroundHeader";
import { import {
PlaygroundTab, PlaygroundTab,
@@ -561,29 +562,15 @@ export default function Playground({
]); ]);
let mobileTabs: PlaygroundTab[] = []; let mobileTabs: PlaygroundTab[] = [];
if (config.settings.outputs.video) { if (config.settings.outputs.video || config.settings.outputs.audio) {
mobileTabs.push({ mobileTabs.push({
title: "Video", title: "Phone",
content: ( content: (
<PlaygroundTile <PlaygroundTile
className="w-full h-full grow" className="w-full h-full grow"
childrenClassName="justify-center" childrenClassName="justify-center"
> >
{videoTileContent} <PhoneSimulator onConnect={() => onConnect(true)} />
</PlaygroundTile>
),
});
}
if (config.settings.outputs.audio) {
mobileTabs.push({
title: "Audio",
content: (
<PlaygroundTile
className="w-full h-full grow"
childrenClassName="justify-center"
>
{audioTileContent}
</PlaygroundTile> </PlaygroundTile>
), ),
}); });
@@ -641,24 +628,13 @@ export default function Playground({
: "flex" : "flex"
}`} }`}
> >
{config.settings.outputs.video && ( <PlaygroundTile
<PlaygroundTile title="Phone"
title="Agent Video" className="w-full h-full grow"
className="w-full h-full grow" childrenClassName="justify-center"
childrenClassName="justify-center" >
> <PhoneSimulator onConnect={() => onConnect(true)} />
{videoTileContent} </PlaygroundTile>
</PlaygroundTile>
)}
{config.settings.outputs.audio && (
<PlaygroundTile
title="Agent Audio"
className="w-full h-full grow"
childrenClassName="justify-center"
>
{audioTileContent}
</PlaygroundTile>
)}
</div> </div>
{config.settings.chat && ( {config.settings.chat && (

View File

@@ -37,3 +37,117 @@ export const ChevronIcon = () => (
/> />
</svg> </svg>
); );
export const BatteryIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="1" y="6" width="18" height="12" rx="2" ry="2"></rect>
<line x1="23" y1="13" x2="23" y2="11"></line>
</svg>
);
export const WifiIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path>
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
<line x1="12" y1="20" x2="12.01" y2="20"></line>
</svg>
);
export const MicIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
);
export const MicOffIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
);
export const PhoneOffIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path>
<line x1="23" y1="1" x2="1" y2="23"></line>
</svg>
);
export const PhoneIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
</svg>
);