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 voiceAssistant = useVoiceAssistant();
const fileInputRef = useRef<HTMLInputElement>(null);
const phoneContainerRef = useRef<HTMLDivElement>(null);
const visualizerRef = useRef<HTMLDivElement>(null);
const [showCameraMenu, setShowCameraMenu] = useState(false);
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 [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(() => {
if (showCameraMenu) {
Room.getLocalDevices("videoinput").then(setCameras);
}
}, [showCameraMenu]);
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -54,6 +113,13 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
};
}, [showCameraMenu]);
useEffect(() => {
if (voiceAssistant.state === "speaking") {
setProcessingImage(null);
setProcessingSource(null);
}
}, [voiceAssistant.state]);
useEffect(() => {
const updateTime = () => {
const now = new Date();
@@ -92,22 +158,128 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
};
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 videoElement = localCameraTrack.attach() as HTMLVideoElement;
const canvas = document.createElement("canvas");
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
// 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) {
ctx.drawImage(videoElement, 0, 0);
// 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.
canvas.toBlob((blob) => {
if (blob && onCapture) {
const file = new File([blob], "camera-capture.jpg", { type: "image/jpeg" });
const file = new File([blob], "camera-capture.jpg", {
type: "image/jpeg",
});
onCapture(file);
}
setIsCapturing(false);
}, "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];
if (file && onCapture) {
onCapture(file);
setProcessingImage(URL.createObjectURL(file));
setProcessingSource("upload");
}
// Reset input so the same file can be selected again
if (event.target) {
@@ -182,6 +356,14 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
.mirror-video video {
transform: scaleX(-1);
}
@keyframes scan {
0% { top: 0%; }
50% { top: 100%; }
100% { top: 0%; }
}
.scan-animation {
animation: scan 3s linear infinite;
}
`}</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">
@@ -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>
{/* 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}
{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
type="file"
@@ -208,7 +410,7 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
/>
{/* Capture Guide Lines */}
{roomState === ConnectionState.Connected && phoneMode === "capture" && (
{roomState === ConnectionState.Connected && phoneMode === "capture" && !processingImage && (
<div className="absolute inset-0 pointer-events-none z-10">
{/* Thirds Grid */}
<div className="absolute top-1/3 left-0 w-full h-[1px] bg-white/20"></div>
@@ -222,9 +424,17 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
</div>
)}
{/* Agent Audio Visualizer (Top Left) */}
{/* Agent Audio Visualizer (Draggable) */}
{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
ref={visualizerRef}
className="absolute z-20 p-2 bg-black/40 backdrop-blur-md rounded-lg border border-white/10 shadow-lg cursor-move select-none"
style={{
left: visualizerPosition.x,
top: visualizerPosition.y,
}}
onMouseDown={handleDragStart}
>
<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}
@@ -239,9 +449,9 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
{/* Call Controls Overlay */}
{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" ? (
<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">
<button
className="p-4 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors shrink-0"
@@ -268,7 +478,9 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
{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">
{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
</div>
) : (
cameras.map((device) => (
<button
@@ -276,7 +488,8 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
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}`}
{device.label ||
`Camera ${cameras.indexOf(device) + 1}`}
</button>
))
)}
@@ -285,7 +498,7 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
</div>
</div>
) : (
<>
<div className="w-full flex items-center justify-center gap-8">
<button
className={`p-4 rounded-full backdrop-blur-md transition-colors ${
!isMicEnabled
@@ -307,7 +520,7 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
>
<PhoneOffIcon className="w-6 h-6" />
</button>
</>
</div>
)}
</div>
)}

View File

@@ -184,7 +184,7 @@ export const SwitchCameraIcon = ({ className }: { className?: string }) => (
strokeLinejoin="round"
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 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="M4 12v-3a3 3 0 0 1 3-3h13m-3-3 3 3-3 3" />
<path d="M20 12v3a3 3 0 0 1-3 3H4m3 3-3-3 3-3" />
</svg>
);