import cn from "classnames"; import React, { memo, ReactNode, RefObject, useEffect, useRef, useState } from "react"; import { useLiveAPIContext } from "../../contexts/LiveAPIContext"; // Hooks for webcam/screen capture if still used with new UI import { useScreenCapture } from "../../hooks/use-screen-capture"; import { useWebcam } from "../../hooks/use-webcam"; import { AudioRecorder } from "../../lib/audio-recorder"; import { isIOS } from "../../lib/platform"; // import AudioPulse from "../audio-pulse/AudioPulse"; // Original pulse, new design has different aesthetic // import "./control-tray.scss"; // Comment out or remove if styles conflict // --- SVG Icon Definitions (inline for simplicity) --- const SvgPauseIcon = () => ; const SvgMicrophoneIcon = () => ; const SvgCameraIcon = () => ; const SvgStopCamIcon = () => ; const SvgSwitchCameraIcon = () => ; const SvgHumanIcon = () => ; // --- End SVG Icon Definitions --- // Styles for ControlTray specifically (Tailwind will handle most, this is for custom parts) const controlTrayStyles = ` .footer-controls { width: 100%; display: flex; gap: 1rem; position: absolute; bottom: 0; padding: 2rem 3rem; align-items: center; z-index: 20; /* Ensure it's above video but below modals */ } .footer-controls.layout-default { justify-content: space-between; } .footer-controls.layout-with-small-logo { justify-content: space-around; } .control-button { height: 80px; width: 80px; border-radius: 9999px; /* rounded-full */ padding: 0; display: flex; align-items: center; justify-content: center; border-width: 1px; /* border */ /* border-color will come from var(--border) via Tailwind's border-border */ box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06); /* shadow-md */ cursor: pointer; transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; } .control-button:hover { transform: scale(1.05); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); /* shadow-lg */ } .cam-button-color { background-color: #E0ECFF; } /* Light blue */ .mic-button-color { background-color: #fecdd3; } /* Light red */ .dark .cam-button-color { background-color: #223355; } /* Darker blue */ .dark .mic-button-color { background-color: #5C2129; } /* Darker red */ .switch-camera-button-container { position: absolute; bottom: calc(100% + 0.65rem); left: 50%; z-index: 5; opacity: 0; transform: translateY(15px) scale(0.7) translateX(-50%); pointer-events: none; transition: opacity 0.35s cubic-bezier(0.68, -0.55, 0.27, 1.55), transform 0.35s cubic-bezier(0.68, -0.55, 0.27, 1.55); transform-origin: center bottom; } .switch-camera-button-container.visible { opacity: 1; transform: translateY(0) scale(1) translateX(-50%); pointer-events: auto; } .switch-camera-button-content { width: 48px; height: 48px; background-color: var(--background); /* Uses themed background */ border: 1px solid var(--border); /* Uses themed border */ border-radius: 9999px; /* rounded-full */ display: flex; align-items: center; justify-content: center; box-shadow: 0 5px 10px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.08); cursor: pointer; transform-origin: center; transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; } .switch-camera-button-content:hover { transform: scale(1.12) rotate(-6deg); box-shadow: 0 7px 15px rgba(0,0,0,0.18), 0 3px 6px rgba(0,0,0,0.12); } .switch-camera-button-content:active { transform: scale(1.03) rotate(0deg); } .switch-camera-button-content svg { /* stroke is set by var(--foreground) or Tailwind text color */ /* width/height set on SvgSwitchCameraIcon component */ } #small-logo-container.flex { display: flex; align-items: center; justify-content: center; } /* Ping animation for logos */ @keyframes ping { 75%, 100% { transform: scale(2); opacity: 0; } } .animate-ping { animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; } `; export type ControlTrayProps = { videoRef: RefObject; children?: ReactNode; // Kept for extensibility, not used by new HTML structure supportsVideo: boolean; onVideoStreamChange?: (stream: MediaStream | null) => void; }; // React component for the logo (replaces createLogoHTML) const LogoDisplay = ({ isMini, isHumanActive, isAiActive = false /* Not used in HTML */ }) => { if (!isHumanActive && !isAiActive) return null; // Don't render if no one is active const size = isMini ? 80 : 200; const iconSize = isMini ? 35 : 70; const insetBase = isMini ? { ping: 10, outer: 0, mid: 5, inner: 12, icon: 22 } : { ping: 40, outer: 0, mid: 20, inner: 50, icon: 65 }; // Values from HTML's JS const bgColorBase = isHumanActive ? 'blue' : (isAiActive ? 'green' : 'gray'); // Tailwind classes for dynamic background colors const pingColor = `bg-${bgColorBase}-200`; const outerRingColor = `bg-${bgColorBase}-200`; const midRingColor = `bg-${bgColorBase}-300`; const innerRingColor = `bg-${bgColorBase}-400`; return (
{isHumanActive && } {/* Add AI icon if isAiActive is true and you have one */}
); }; function ControlTray({ videoRef, onVideoStreamChange = () => {}, supportsVideo, }: ControlTrayProps) { const webcam = useWebcam(); const screenCapture = useScreenCapture(); // Kept if screen share button is re-added const [isCamActive, setIsCamActive] = useState(false); // New state for camera (visual) const [isMicActive, setIsMicActive] = useState(false); // New state for mic (visual) const [currentFacingMode, setCurrentFacingMode] = useState<'user' | 'environment'>('user'); const [isSwitchingCamera, setIsSwitchingCamera] = useState(false); const [audioRecorder] = useState(() => new AudioRecorder()); // const [inVolume, setInVolume] = useState(0); // For original AudioPulse const { client, connected, connect, disconnect, volume, setConfig } = useLiveAPIContext(); // Connect to API when component mounts or when mic/cam are first activated useEffect(() => { // This is a placeholder for connection logic. // The original app had an explicit connect button. // You might want to connect automatically, or when mic/cam is first toggled. if (!connected && (isMicActive || isCamActive)) { // connect(); // Example: connect when mic/cam active } }, [isMicActive, isCamActive, connected, connect]); // Audio recording based on isMicActive AND connected useEffect(() => { const onData = (base64: string) => { if (client && connected && isMicActive) { // Check isMicActive client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]); } }; // const onVolume = setInVolume; // For original AudioPulse if (connected && isMicActive && audioRecorder) { audioRecorder.on("data", onData)/*.on("volume", onVolume)*/.start(); } else if (audioRecorder) { audioRecorder.stop(); } return () => { if (audioRecorder) { audioRecorder.off("data", onData)/*.off("volume", onVolume)*/.stop(); } }; }, [connected, client, isMicActive, audioRecorder]); // Video stream management useEffect(() => { const videoElement = videoRef.current; if (videoElement) { if (isCamActive && webcam.stream) { videoElement.srcObject = webcam.stream; videoElement.play().catch(e => console.warn("Video play failed:", e)); onVideoStreamChange(webcam.stream); } else { videoElement.srcObject = null; onVideoStreamChange(null); } } }, [isCamActive, webcam.stream, videoRef, onVideoStreamChange]); // Video frame sending (simplified, adapt frame rate as needed) useEffect(() => { let frameSenderId: number; const sendFrame = () => { if (connected && isCamActive && webcam.stream && videoRef.current && client) { const video = videoRef.current; const canvas = document.createElement('canvas'); // Temporary canvas const scale = 0.25; canvas.width = video.videoWidth * scale; canvas.height = video.videoHeight * scale; if (canvas.width > 0 && canvas.height > 0) { const ctx = canvas.getContext("2d"); if (ctx) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const base64 = canvas.toDataURL("image/jpeg", 0.8); const data = base64.slice(base64.indexOf(",") + 1); client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]); } } } frameSenderId = window.setTimeout(sendFrame, 1000 / 2); // ~2 FPS, adjust as needed }; if (isCamActive && connected) { frameSenderId = window.setTimeout(sendFrame, 200); // Initial delay } return () => clearTimeout(frameSenderId); }, [connected, isCamActive, webcam.stream, client, videoRef]); const handleToggleMic = async () => { if (!connected) { // Auto-connect if not connected try { await connect(); } catch (err) { console.error("Failed to connect on mic toggle:", err); return; // Don't toggle mic if connection fails } } // After ensuring connection (or if already connected) setIsMicActive(prev => !prev); // The audioRecorder useEffect will handle starting/stopping based on isMicActive & connected }; const handleToggleCam = async () => { if (isSwitchingCamera) return; const newCamState = !isCamActive; if (newCamState) { // Turning camera ON if (!connected) { // Auto-connect if not connected try { await connect(); } catch (err) { console.error("Failed to connect on cam toggle:", err); return; } } try { await webcam.start({ video: { facingMode: currentFacingMode }, audio: false }); setIsCamActive(true); } catch (error) { console.error("Error starting webcam:", error); setIsCamActive(false); // Ensure it's off if start failed } } else { // Turning camera OFF webcam.stop(); setIsCamActive(false); // Optional: Disconnect if both mic and cam are off // if (!isMicActive && !newCamState && connected) { // disconnect(); // } } }; const handleSwitchCamera = async () => { if (!isCamActive || isSwitchingCamera) return; setIsSwitchingCamera(true); const newFacingMode = currentFacingMode === 'user' ? 'environment' : 'user'; try { await webcam.stop(); // Stop current stream await webcam.start({ video: { facingMode: { exact: newFacingMode } }, audio: false }); setCurrentFacingMode(newFacingMode); // onVideoStreamChange already handled by webcam.stream effect } catch (error) { console.error("Error switching camera:", error); // Try to restore previous mode or stop try { await webcam.start({ video: { facingMode: currentFacingMode }, audio: false }); } catch (restoreError) { console.error("Error restoring camera:", restoreError); setIsCamActive(false); // Turn off cam if all fails webcam.stop(); } } finally { setIsSwitchingCamera(false); } }; // Determine layout for footer controls const footerLayoutClass = isCamActive ? 'layout-with-small-logo' : 'layout-default'; // Update large logo visibility in App.tsx (this is tricky, App.tsx owns that element) // For simplicity, we'll just log. A better way is via context or Zustand. useEffect(() => { const largeLogoEl = document.getElementById('large-logo-container'); if (largeLogoEl) { if (!isCamActive && isMicActive) { largeLogoEl.innerHTML = ''; // Clear previous const logoDiv = document.createElement('div'); // This is a hacky way to render the LogoDisplay component's HTML. // ReactDOM.render(, logoDiv); // This won't work directly here. // For now, just show/hide. Proper rendering needs state lift or context. // You might need to pass isMicActive up to App.tsx to render the large logo there. largeLogoEl.classList.remove('hidden'); largeLogoEl.classList.add('flex'); // Ideally, render inside it from App.tsx } else { largeLogoEl.classList.add('hidden'); largeLogoEl.classList.remove('flex'); largeLogoEl.innerHTML = ''; } } }, [isCamActive, isMicActive]); return ( <> ); } export default memo(ControlTray);