change icon, make transition animation during analyzing image

This commit is contained in:
2025-12-05 17:12:59 +08:00
parent 3180923f75
commit 3c9a7cf3af
2 changed files with 310 additions and 97 deletions

View File

@@ -28,17 +28,76 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
const tracks = useTracks(); const tracks = useTracks();
const voiceAssistant = useVoiceAssistant(); const voiceAssistant = useVoiceAssistant();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const phoneContainerRef = useRef<HTMLDivElement>(null);
const visualizerRef = useRef<HTMLDivElement>(null);
const [showCameraMenu, setShowCameraMenu] = useState(false); const [showCameraMenu, setShowCameraMenu] = useState(false);
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]); const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
const [processingImage, setProcessingImage] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const [processingSource, setProcessingSource] = useState<
"camera" | "upload" | null
>(null);
const [currentTime, setCurrentTime] = useState(""); const [currentTime, setCurrentTime] = useState("");
const [visualizerPosition, setVisualizerPosition] = useState({
x: 16,
y: 56,
});
const [isDragging, setIsDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const handleDragStart = (e: React.MouseEvent) => {
setIsDragging(true);
dragOffset.current = {
x: e.clientX - visualizerPosition.x,
y: e.clientY - visualizerPosition.y,
};
};
const handleDragMove = (e: MouseEvent) => {
if (!isDragging || !phoneContainerRef.current || !visualizerRef.current) return;
const containerRect = phoneContainerRef.current.getBoundingClientRect();
const visualizerRect = visualizerRef.current.getBoundingClientRect();
let newX = e.clientX - dragOffset.current.x;
let newY = e.clientY - dragOffset.current.y;
// Constrain within container
const maxX = containerRect.width - visualizerRect.width;
const maxY = containerRect.height - visualizerRect.height;
const statusBarHeight = 48; // h-12 = 48px
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(statusBarHeight, Math.min(newY, maxY));
setVisualizerPosition({
x: newX,
y: newY,
});
};
const handleDragEnd = () => {
setIsDragging(false);
};
useEffect(() => {
if (isDragging) {
window.addEventListener("mouseup", handleDragEnd);
window.addEventListener("mousemove", handleDragMove);
}
return () => {
window.removeEventListener("mouseup", handleDragEnd);
window.removeEventListener("mousemove", handleDragMove);
};
}, [isDragging]);
useEffect(() => { useEffect(() => {
if (showCameraMenu) { if (showCameraMenu) {
Room.getLocalDevices("videoinput").then(setCameras); Room.getLocalDevices("videoinput").then(setCameras);
} }
}, [showCameraMenu]); }, [showCameraMenu]);
// Close menu when clicking outside // Close menu when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@@ -54,6 +113,13 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
}; };
}, [showCameraMenu]); }, [showCameraMenu]);
useEffect(() => {
if (voiceAssistant.state === "speaking") {
setProcessingImage(null);
setProcessingSource(null);
}
}, [voiceAssistant.state]);
useEffect(() => { useEffect(() => {
const updateTime = () => { const updateTime = () => {
const now = new Date(); const now = new Date();
@@ -92,22 +158,128 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
}; };
const handleCapture = async () => { const handleCapture = async () => {
if (!localCameraTrack || !onCapture) return; if (!localCameraTrack || !onCapture || isCapturing) return;
setIsCapturing(true);
const trackReference = localCameraTrack as any;
// Handle both TrackReference (from useTracks) and potential direct Track objects
const track =
trackReference.publication?.track?.mediaStreamTrack ||
trackReference.mediaStreamTrack;
if (!track) {
console.error("No media stream track found");
setIsCapturing(false);
return;
}
const video = document.createElement("video");
video.srcObject = new MediaStream([track]);
video.muted = true;
video.playsInline = true;
video.autoplay = true;
// Element needs to be in the DOM for some browsers to play it properly
video.style.position = "absolute";
video.style.top = "-9999px";
video.style.left = "-9999px";
document.body.appendChild(video);
try {
await video.play();
// Wait for video dimensions to be available
if (video.videoWidth === 0 || video.videoHeight === 0) {
await new Promise<void>((resolve) => {
video.onloadedmetadata = () => resolve();
// Timeout to prevent hanging
setTimeout(resolve, 1000);
});
}
const canvas = document.createElement("canvas");
// Default to video dimensions
let renderWidth = video.videoWidth;
let renderHeight = video.videoHeight;
let sourceX = 0;
let sourceY = 0;
let sourceWidth = video.videoWidth;
let sourceHeight = video.videoHeight;
// If the video is landscape but we want a portrait crop (like a phone)
// We want an aspect ratio of roughly 9:19.5 (from the container styles)
const targetAspect = 9 / 19.5;
const videoAspect = video.videoWidth / video.videoHeight;
if (videoAspect > targetAspect) {
// Video is wider than target - crop width
const newWidth = video.videoHeight * targetAspect;
sourceX = (video.videoWidth - newWidth) / 2;
sourceWidth = newWidth;
renderWidth = newWidth;
} else {
// Video is taller than target - crop height (less common for landscape webcam)
const newHeight = video.videoWidth / targetAspect;
sourceY = (video.videoHeight - newHeight) / 2;
sourceHeight = newHeight;
renderHeight = newHeight;
}
canvas.width = renderWidth;
canvas.height = renderHeight;
const ctx = canvas.getContext("2d");
if (ctx) {
// Mirror the image to match the preview
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
// Draw only the cropped portion of the video
ctx.drawImage(
video,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
renderWidth,
renderHeight
);
// Reset transform
ctx.setTransform(1, 0, 0, 1, 0, 0);
// Use toDataURL for immediate preview feedback
const dataUrl = canvas.toDataURL("image/jpeg");
setProcessingImage(dataUrl);
setProcessingSource("camera");
// Create a new canvas for the final output (unmirrored if needed, but user requested mirrored)
// The user requested to mirror the shuttled photo, which we did above for the canvas.
// So the blob created from this canvas will also be mirrored.
const videoElement = localCameraTrack.attach() as HTMLVideoElement;
const canvas = document.createElement("canvas");
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(videoElement, 0, 0);
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob && onCapture) { if (blob && onCapture) {
const file = new File([blob], "camera-capture.jpg", { type: "image/jpeg" }); const file = new File([blob], "camera-capture.jpg", {
onCapture(file); type: "image/jpeg",
} });
onCapture(file);
}
setIsCapturing(false);
}, "image/jpeg"); }, "image/jpeg");
} else {
setIsCapturing(false);
}
} catch (e) {
console.error("Failed to capture image", e);
setIsCapturing(false);
} finally {
// Cleanup
video.pause();
video.srcObject = null;
if (document.body.contains(video)) {
document.body.removeChild(video);
}
video.remove();
} }
}; };
@@ -131,6 +303,8 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file && onCapture) { if (file && onCapture) {
onCapture(file); onCapture(file);
setProcessingImage(URL.createObjectURL(file));
setProcessingSource("upload");
} }
// Reset input so the same file can be selected again // Reset input so the same file can be selected again
if (event.target) { if (event.target) {
@@ -182,6 +356,14 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
.mirror-video video { .mirror-video video {
transform: scaleX(-1); transform: scaleX(-1);
} }
@keyframes scan {
0% { top: 0%; }
50% { top: 100%; }
100% { top: 0%; }
}
.scan-animation {
animation: scan 3s linear infinite;
}
`}</style> `}</style>
{/* Status Bar */} {/* 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"> <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">
@@ -196,8 +378,28 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-7 bg-black rounded-b-2xl z-30"></div> <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 */} {/* Main Content */}
<div className="flex-grow relative bg-gray-950 w-full h-full"> <div ref={phoneContainerRef} className="flex-grow relative bg-gray-950 w-full h-full">
{videoContent} {videoContent}
{processingImage && (
<div className="absolute inset-0 z-10 bg-black flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={processingImage}
alt="Processing"
className={`w-full h-full opacity-50 ${
processingSource === "camera"
? "object-cover scale-x-[-1]"
: "object-contain"
}`}
/>
<div className="absolute inset-0 overflow-hidden">
<div className="w-full h-[2px] bg-blue-500 shadow-[0_0_15px_rgba(59,130,246,1)] scan-animation absolute"></div>
</div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full text-center text-blue-400 font-mono text-sm animate-pulse z-20">
...
</div>
</div>
)}
<input <input
type="file" type="file"
@@ -208,7 +410,7 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
/> />
{/* Capture Guide Lines */} {/* Capture Guide Lines */}
{roomState === ConnectionState.Connected && phoneMode === "capture" && ( {roomState === ConnectionState.Connected && phoneMode === "capture" && !processingImage && (
<div className="absolute inset-0 pointer-events-none z-10"> <div className="absolute inset-0 pointer-events-none z-10">
{/* Thirds Grid */} {/* Thirds Grid */}
<div className="absolute top-1/3 left-0 w-full h-[1px] bg-white/20"></div> <div className="absolute top-1/3 left-0 w-full h-[1px] bg-white/20"></div>
@@ -222,93 +424,104 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
</div> </div>
)} )}
{/* Agent Audio Visualizer (Top Left) */} {/* Agent Audio Visualizer (Draggable) */}
{roomState === ConnectionState.Connected && voiceAssistant.audioTrack && ( {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
<div className="h-8 w-24 flex items-center justify-center [--lk-va-bar-width:3px] [--lk-va-bar-gap:2px] [--lk-fg:white]"> ref={visualizerRef}
<BarVisualizer className="absolute z-20 p-2 bg-black/40 backdrop-blur-md rounded-lg border border-white/10 shadow-lg cursor-move select-none"
state={voiceAssistant.state} style={{
trackRef={voiceAssistant.audioTrack} left: visualizerPosition.x,
barCount={7} top: visualizerPosition.y,
options={{ minHeight: 5 }} }}
/> onMouseDown={handleDragStart}
</div> >
<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>
)} )}
</div> </div>
{/* Call Controls Overlay */} {/* Call Controls Overlay */}
{roomState === ConnectionState.Connected && ( {roomState === ConnectionState.Connected && (
<div className="absolute bottom-8 left-0 w-full px-8 flex items-center justify-between z-20"> <div className="absolute bottom-8 left-0 w-full px-8 z-20">
{phoneMode === "capture" ? ( {phoneMode === "capture" ? (
<div className="w-full grid grid-cols-3 items-center px-8"> <div className="w-full grid grid-cols-3 items-center">
<div className="flex justify-start"> <div className="flex justify-start">
<button <button
className="p-4 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors shrink-0" className="p-4 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors shrink-0"
onClick={handleUpload} onClick={handleUpload}
> >
<ImageIcon className="w-6 h-6" /> <ImageIcon className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
className="w-20 h-20 rounded-full border-4 border-white p-1 hover:scale-105 transition-transform shrink-0 aspect-square" className="w-20 h-20 rounded-full border-4 border-white p-1 hover:scale-105 transition-transform shrink-0 aspect-square"
onClick={handleCapture} onClick={handleCapture}
> >
<div className="w-full h-full bg-white rounded-full"></div> <div className="w-full h-full bg-white rounded-full"></div>
</button> </button>
</div> </div>
<div className="flex justify-end relative"> <div className="flex justify-end relative">
<button <button
className="p-4 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors shrink-0" className="p-4 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors shrink-0"
onClick={handleSwitchCamera} onClick={handleSwitchCamera}
> >
<SwitchCameraIcon className="w-6 h-6" /> <SwitchCameraIcon className="w-6 h-6" />
</button> </button>
{showCameraMenu && ( {showCameraMenu && (
<div className="absolute bottom-16 right-0 bg-gray-900 border border-gray-800 rounded-lg shadow-xl py-2 w-48 z-50"> <div className="absolute bottom-16 right-0 bg-gray-900 border border-gray-800 rounded-lg shadow-xl py-2 w-48 z-50">
{cameras.length === 0 ? ( {cameras.length === 0 ? (
<div className="px-4 py-2 text-gray-500 text-sm">No cameras found</div> <div className="px-4 py-2 text-gray-500 text-sm">
) : ( No cameras found
cameras.map((device) => ( </div>
<button
key={device.deviceId}
onClick={() => handleSelectCamera(device.deviceId)}
className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-800 transition-colors truncate"
>
{device.label || `Camera ${cameras.indexOf(device) + 1}`}
</button>
))
)}
</div>
)}
</div>
</div>
) : (
<>
<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" /> cameras.map((device) => (
<button
key={device.deviceId}
onClick={() => handleSelectCamera(device.deviceId)}
className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-800 transition-colors truncate"
>
{device.label ||
`Camera ${cameras.indexOf(device) + 1}`}
</button>
))
)} )}
</button> </div>
)}
<button </div>
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors" </div>
onClick={handleDisconnect} ) : (
> <div className="w-full flex items-center justify-center gap-8">
<PhoneOffIcon className="w-6 h-6" /> <button
</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> </div>
)} )}
</div> </div>

View File

@@ -184,7 +184,7 @@ export const SwitchCameraIcon = ({ className }: { className?: string }) => (
strokeLinejoin="round" strokeLinejoin="round"
className={className} className={className}
> >
<path d="M20 4v5h-.58c-.89-3.1-3.85-5.23-7.17-5.23-4.26 0-7.74 3.4-7.74 7.6 0 .65.09 1.29.26 1.91l1.69-1.69c-.14-.22-.2-.46-.2-.72 0-3.2 2.63-5.81 5.87-5.81 2.67 0 4.96 1.75 5.6 4.23h-1.73l2.5 2.5 2.5-2.5h-1z" /> <path d="M4 12v-3a3 3 0 0 1 3-3h13m-3-3 3 3-3 3" />
<path d="M4 20v-5h.58c.89 3.1 3.85 5.23 7.17 5.23 4.26 0 7.74-3.4 7.74-7.6 0-.65-.09-1.29-.26-1.91l-1.69 1.69c.14.22.2.46.2.72 0 3.2-2.63 5.81-5.87 5.81-2.67 0-4.96-1.75-5.6-4.23h1.73l-2.5-2.5-2.5 2.5h1z" /> <path d="M20 12v3a3 3 0 0 1-3 3H4m3 3-3-3 3-3" />
</svg> </svg>
); );