add phone simulator
This commit is contained in:
173
src/components/playground/PhoneSimulator.tsx
Normal file
173
src/components/playground/PhoneSimulator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ColorPicker } from "@/components/colorPicker/ColorPicker";
|
||||
import { AudioInputTile } from "@/components/config/AudioInputTile";
|
||||
import { ConfigurationPanelItem } from "@/components/config/ConfigurationPanelItem";
|
||||
import { NameValueRow } from "@/components/config/NameValueRow";
|
||||
import { PhoneSimulator } from "@/components/playground/PhoneSimulator";
|
||||
import { PlaygroundHeader } from "@/components/playground/PlaygroundHeader";
|
||||
import {
|
||||
PlaygroundTab,
|
||||
@@ -561,29 +562,15 @@ export default function Playground({
|
||||
]);
|
||||
|
||||
let mobileTabs: PlaygroundTab[] = [];
|
||||
if (config.settings.outputs.video) {
|
||||
if (config.settings.outputs.video || config.settings.outputs.audio) {
|
||||
mobileTabs.push({
|
||||
title: "Video",
|
||||
title: "Phone",
|
||||
content: (
|
||||
<PlaygroundTile
|
||||
className="w-full h-full grow"
|
||||
childrenClassName="justify-center"
|
||||
>
|
||||
{videoTileContent}
|
||||
</PlaygroundTile>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (config.settings.outputs.audio) {
|
||||
mobileTabs.push({
|
||||
title: "Audio",
|
||||
content: (
|
||||
<PlaygroundTile
|
||||
className="w-full h-full grow"
|
||||
childrenClassName="justify-center"
|
||||
>
|
||||
{audioTileContent}
|
||||
<PhoneSimulator onConnect={() => onConnect(true)} />
|
||||
</PlaygroundTile>
|
||||
),
|
||||
});
|
||||
@@ -641,24 +628,13 @@ export default function Playground({
|
||||
: "flex"
|
||||
}`}
|
||||
>
|
||||
{config.settings.outputs.video && (
|
||||
<PlaygroundTile
|
||||
title="Agent Video"
|
||||
title="Phone"
|
||||
className="w-full h-full grow"
|
||||
childrenClassName="justify-center"
|
||||
>
|
||||
{videoTileContent}
|
||||
<PhoneSimulator onConnect={() => onConnect(true)} />
|
||||
</PlaygroundTile>
|
||||
)}
|
||||
{config.settings.outputs.audio && (
|
||||
<PlaygroundTile
|
||||
title="Agent Audio"
|
||||
className="w-full h-full grow"
|
||||
childrenClassName="justify-center"
|
||||
>
|
||||
{audioTileContent}
|
||||
</PlaygroundTile>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.settings.chat && (
|
||||
|
||||
@@ -37,3 +37,117 @@ export const ChevronIcon = () => (
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user