/** Copyright 2024 Google LLC ... (لایسنس و توضیحات دیگر مثل قبل) ... */ import cn from "classnames"; import React, { memo, ReactNode, RefObject, useEffect, useState } from "react"; import { useLiveAPIContext } from "../../contexts/LiveAPIContext"; import { AudioRecorder } from "../../lib/audio-recorder"; // --- NEW SVG Icons from your HTML --- // Make sure these are defined correctly as React components // or adjust the rendering logic if you keep them as raw strings/elements. // Mic Icon (from your HTML's svg-microphoneIcon) const SvgUserMicrophoneIcon = () => ( ); // Pause Icon (from your HTML's svg-pauseIcon) const SvgUserPauseIcon = () => ( ); // Camera Icon (from your HTML's svg-cameraIcon) const SvgUserCameraIcon = () => ( ); // Stop Camera Icon (from your HTML's svg-stopCamIcon) const SvgUserStopCamIcon = () => ( ); // Switch Camera Icon (from your HTML) const SvgUserSwitchCameraIcon = () => ( {/* class for styling from App.scss */} ); // --- End of new SVG Icons --- export type ControlTrayProps = { videoRef: RefObject; supportsVideo: boolean; onVideoStreamChange: (stream: MediaStream | null) => void; isAppMicActive: boolean; onAppMicToggle: (active: boolean) => void; isAppCamActive: boolean; onAppCamToggle: (active: boolean) => void; createLogoFunction: (isMini: boolean, isActive: boolean, type?: 'human' | 'ai') => ReactNode; }; const ControlTray: React.FC = ({ videoRef, onVideoStreamChange, supportsVideo, isAppMicActive, onAppMicToggle, isAppCamActive, onAppCamToggle, createLogoFunction, }) => { const { client, connected, connect } = useLiveAPIContext(); const [audioRecorder] = useState(() => new AudioRecorder()); const [activeLocalVideoStream, setActiveLocalVideoStream] = useState(null); const [currentFacingMode, setCurrentFacingMode] = useState<'user' | 'environment'>('user'); const [isSwitchingCamera, setIsSwitchingCamera] = useState(false); const renderCanvasRef = React.useRef(null); useEffect(() => { if (isAppCamActive && !activeLocalVideoStream && !isSwitchingCamera) { startWebcam(); } else if (!isAppCamActive && activeLocalVideoStream) { stopWebcam(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAppCamActive, activeLocalVideoStream, isSwitchingCamera]); // Consider adding startWebcam/stopWebcam if they change useEffect(() => { const onData = (base64: string) => { if (client && connected && isAppMicActive) { client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]); } }; if (connected && isAppMicActive && audioRecorder) { audioRecorder.on("data", onData).start(); } else if (audioRecorder && audioRecorder.recording) { audioRecorder.stop(); } return () => { if (audioRecorder) { audioRecorder.off("data", onData); if (audioRecorder.recording) audioRecorder.stop(); } }; }, [connected, client, isAppMicActive, audioRecorder]); useEffect(() => { let timeoutId = -1; function sendVideoFrame() { if (connected && activeLocalVideoStream && client && videoRef.current) { const video = videoRef.current; const canvas = renderCanvasRef.current; if (!canvas || video.readyState < video.HAVE_METADATA || video.paused || video.ended) { if (activeLocalVideoStream) timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4); return; } try { const ctx = canvas.getContext("2d"); if (!ctx) return; const scale = 0.5; canvas.width = video.videoWidth * scale; canvas.height = video.videoHeight * scale; if (canvas.width > 0 && canvas.height > 0) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const base64 = canvas.toDataURL("image/jpeg", 0.8); const data = base64.slice(base64.indexOf(",") + 1); if (data) client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]); } } catch (error) { console.error("❌ Error frame:", error); } } if (connected && activeLocalVideoStream) { timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4); // Send 4 frames per second } } if (connected && activeLocalVideoStream && videoRef.current) { timeoutId = window.setTimeout(sendVideoFrame, 200); // Initial delay } return () => clearTimeout(timeoutId); }, [connected, activeLocalVideoStream, client, videoRef, renderCanvasRef]); useEffect(() => { if (videoRef.current) { if (videoRef.current.srcObject !== activeLocalVideoStream) { videoRef.current.srcObject = activeLocalVideoStream; if (activeLocalVideoStream) { videoRef.current.play().catch(e => console.warn("Video play failed:", e)); } } } }, [activeLocalVideoStream, videoRef]); const ensureConnectedAndReady = async (): Promise => { if (!connected) { try { await connect(); return true; } catch (err) { console.error('❌ CT Connect err:', err); return false; } } return true; }; const handleMicToggle = async () => { if (isSwitchingCamera) return; const newMicState = !isAppMicActive; if (newMicState && !(await ensureConnectedAndReady())) { onAppMicToggle(false); return; } onAppMicToggle(newMicState); // If activating mic and cam is also active, ensure cam stays on user's explicit toggle. if (newMicState && isAppCamActive) { // No change to cam state, it's already explicitly managed } else if (newMicState && !isAppCamActive) { // Mic on, Cam off - large logo state handled by AppInternalLogic } }; const startWebcam = async (facingModeToTry: 'user' | 'environment' = currentFacingMode) => { if (isSwitchingCamera) return; setIsSwitchingCamera(true); try { const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingModeToTry }, audio: false }); setActiveLocalVideoStream(mediaStream); onVideoStreamChange(mediaStream); setCurrentFacingMode(facingModeToTry); } catch (error) { console.error(`❌ Start WC err ${facingModeToTry}:`, error); setActiveLocalVideoStream(null); onVideoStreamChange(null); onAppCamToggle(false); // Turn off cam if failed } finally { setIsSwitchingCamera(false); } }; const stopWebcam = () => { if (activeLocalVideoStream) activeLocalVideoStream.getTracks().forEach(track => track.stop()); setActiveLocalVideoStream(null); onVideoStreamChange(null); }; const handleCamToggle = async () => { if (isSwitchingCamera) return; const newCamState = !isAppCamActive; if (newCamState) { // Turning camera ON if (!(await ensureConnectedAndReady())) { onAppCamToggle(false); return; } // If mic is not active, activate it because video implies audio for Gemini. // However, the user might want cam only. For now, let's keep it simple. // If Gemini requires audio with video, then this is okay: if (!isAppMicActive) onAppMicToggle(true); onAppCamToggle(true); // This will trigger startWebcam via useEffect } else { // Turning camera OFF onAppCamToggle(false); // This will trigger stopWebcam via useEffect } }; const handleSwitchCamera = async () => { if (!isAppCamActive || !activeLocalVideoStream || isSwitchingCamera) return; setIsSwitchingCamera(true); const targetFacingMode = currentFacingMode === 'user' ? 'environment' : 'user'; activeLocalVideoStream.getTracks().forEach(track => track.stop()); // Stop current stream before getting new one try { // Try with 'exact' first for better control const newStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: targetFacingMode } }, audio: false }); setActiveLocalVideoStream(newStream); onVideoStreamChange(newStream); setCurrentFacingMode(targetFacingMode); } catch (error) { console.warn(`❌ Switch Cam to '${targetFacingMode}' (exact) failed:`, error, "Trying without exact."); // Fallback to non-exact if 'exact' fails (some browsers/devices are picky) try { const newStreamFallback = await navigator.mediaDevices.getUserMedia({ video: { facingMode: targetFacingMode }, audio: false }); setActiveLocalVideoStream(newStreamFallback); onVideoStreamChange(newStreamFallback); setCurrentFacingMode(targetFacingMode); } catch (fallbackError) { console.error(`❌ Switch Cam to '${targetFacingMode}' (fallback) also failed:`, fallbackError); // Restore previous stream if possible, or turn off cam try { const restoredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: currentFacingMode }, audio: false }); setActiveLocalVideoStream(restoredStream); onVideoStreamChange(restoredStream); } catch (restoreError) { console.error('❌ Restore Cam err:', restoreError); setActiveLocalVideoStream(null); onVideoStreamChange(null); onAppCamToggle(false); } } } finally { setIsSwitchingCamera(false); } }; // Determine layout for footer controls based on active states // The actual positioning of the logo (large vs small) is handled in AppInternalLogic and CSS const footerLayoutClass = isAppCamActive ? "layout-with-small-logo" : "layout-default"; return ( // The footer-controls class handles positioning at the bottom. // The inner structure will define how items are laid out. ); }; export default memo(ControlTray);