/** Copyright 2024 Google LLC ... (لایسنس و توضیحات دیگر مثل قبل) ... */ import cn from "classnames"; import React, { memo, ReactNode, RefObject, useEffect, useRef, useState } from "react"; import { useLiveAPIContext } from "../../contexts/LiveAPIContext"; import { AudioRecorder } from "../../lib/audio-recorder"; // SVG Icons for Controls const SvgPauseIcon = () => ; const SvgMicrophoneIcon = () => ; const SvgCameraIcon = () => ; const SvgStopCamIcon = () => ; const SvgSwitchCameraIcon = () => ; export type ControlTrayProps = { videoRef: RefObject; children?: ReactNode; 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; }; function ControlTray({ videoRef, onVideoStreamChange, supportsVideo, isAppMicActive, onAppMicToggle, isAppCamActive, onAppCamToggle, createLogoFunction, }: ControlTrayProps) { const { client, connected, connect, disconnect } = useLiveAPIContext(); const [audioRecorder] = useState(() => new AudioRecorder()); const [muted, setMuted] = useState(true); // Internal state for AudioRecorder const [inVolume, setInVolume] = useState(0); // For visual feedback if needed const [activeLocalVideoStream, setActiveLocalVideoStream] = useState(null); const [currentFacingMode, setCurrentFacingMode] = useState<'user' | 'environment'>('user'); const [isSwitchingCamera, setIsSwitchingCamera] = useState(false); const renderCanvasRef = useRef(null); // Sync internal muted state with app-level mic active state useEffect(() => { setMuted(!isAppMicActive); }, [isAppMicActive]); // Manage webcam based on app-level camera active state useEffect(() => { if (isAppCamActive && !activeLocalVideoStream && !isSwitchingCamera) { startWebcam(); } else if (!isAppCamActive && activeLocalVideoStream) { stopWebcam(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAppCamActive]); // Removed activeLocalVideoStream to prevent loops on start failure // Audio Recorder Logic useEffect(() => { const onData = (base64: string) => { if (client && connected && isAppMicActive) { // Send audio if mic is on at app level client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]); } }; if (connected && isAppMicActive && audioRecorder) { // Use isAppMicActive here audioRecorder.on("data", onData).on("volume", setInVolume).start(); } else if (audioRecorder && audioRecorder.recording) { audioRecorder.stop(); } return () => { if (audioRecorder) { audioRecorder.off("data", onData).off("volume", setInVolume); if (audioRecorder.recording) audioRecorder.stop(); } }; }, [connected, client, isAppMicActive, audioRecorder]); // Depend on isAppMicActive // Send Video Frame Logic (with increased frequency and quality) useEffect(() => { let timeoutId = -1; function sendVideoFrame() { if (connected && activeLocalVideoStream && client && videoRef.current) { const video = videoRef.current; const canvas = renderCanvasRef.current; // Use the ref for canvas if (!canvas || video.readyState < video.HAVE_METADATA || video.paused || video.ended) { if (activeLocalVideoStream) timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4); // Retry if stream active return; } try { const ctx = canvas.getContext("2d"); if (!ctx) return; const scale = 0.5; // Increased scale for better quality 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); // Good quality const data = base64.slice(base64.indexOf(",") + 1); if (data) { // console.log(`🖼️ Sending video frame (length: ${data.length}) at ${new Date().toLocaleTimeString()}`); client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]); } } } catch (error) { console.error("❌ Error processing video frame:", error); } } // Schedule next frame if conditions still met if (connected && activeLocalVideoStream) { timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4); // Send ~4 FPS } } if (connected && activeLocalVideoStream && videoRef.current) { console.log("🚀 Initializing video frame sending..."); timeoutId = window.setTimeout(sendVideoFrame, 200); // Initial delay } return () => { clearTimeout(timeoutId); console.log("🛑 Video frame sending stopped."); }; }, [connected, activeLocalVideoStream, client, videoRef, renderCanvasRef]); // Assign stream to video element 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 ensureConnected = async () => { if (!connected) { try { await connect(); return true; } catch (err) { console.error('❌ Connection error in ensureConnected:', err); return false; } } return true; }; const handleMicToggle = async () => { if (isSwitchingCamera) return; const newMicState = !isAppMicActive; if (newMicState) { // Turning mic ON if(!await ensureConnected()) { onAppMicToggle(false); // Keep it off if connection fails return; } } onAppMicToggle(newMicState); // If turning off and cam is also off, consider disconnecting // if (!newMicState && !isAppCamActive && connected) { // disconnect(); // } }; const startWebcam = async (facingModeToTry: 'user' | 'environment' = currentFacingMode) => { if (isSwitchingCamera) return; setIsSwitchingCamera(true); if(!await ensureConnected()) { onAppCamToggle(false); setIsSwitchingCamera(false); return; } try { const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingModeToTry }, audio: false, }); setActiveLocalVideoStream(mediaStream); onVideoStreamChange(mediaStream); setCurrentFacingMode(facingModeToTry); // onAppCamToggle(true); // This will be set by the useEffect in App.tsx if isAppCamActive changes } catch (error) { console.error(`❌ Error starting webcam with ${facingModeToTry}:`, error); // Fallback or error handling logic... setActiveLocalVideoStream(null); onVideoStreamChange(null); onAppCamToggle(false); // Explicitly turn off if start fails } finally { setIsSwitchingCamera(false); } }; const stopWebcam = () => { if (activeLocalVideoStream) { activeLocalVideoStream.getTracks().forEach(track => track.stop()); } setActiveLocalVideoStream(null); onVideoStreamChange(null); // onAppCamToggle(false); // This will be set by the useEffect in App.tsx }; const handleCamToggle = () => { if (isSwitchingCamera) return; onAppCamToggle(!isAppCamActive); // Let App.tsx manage the state and trigger start/stopWebcam }; const handleSwitchCamera = async () => { if (!isAppCamActive || !activeLocalVideoStream || isSwitchingCamera) return; setIsSwitchingCamera(true); const targetFacingMode = currentFacingMode === 'user' ? 'environment' : 'user'; // Stop current stream first activeLocalVideoStream.getTracks().forEach(track => track.stop()); // setActiveLocalVideoStream(null); // Let new stream assignment handle this // onVideoStreamChange(null); try { const newStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: targetFacingMode } }, audio: false, }); setActiveLocalVideoStream(newStream); // This will trigger videoRef update onVideoStreamChange(newStream); setCurrentFacingMode(targetFacingMode); } catch (error) { console.error(`❌ Error switching camera to ${targetFacingMode}:`, error); // Attempt to restore previous stream or stop try { const restoredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: currentFacingMode }, audio: false }); setActiveLocalVideoStream(restoredStream); onVideoStreamChange(restoredStream); } catch (restoreError) { console.error('❌ Error restoring camera:', restoreError); setActiveLocalVideoStream(null); // Ensure stream is cleared onVideoStreamChange(null); onAppCamToggle(false); // Turn off cam at app level if all fails } } finally { setIsSwitchingCamera(false); } }; // Disconnect logic (optional: if both mic and cam are off) useEffect(() => { if (!isAppMicActive && !isAppCamActive && connected) { console.log("🎤❌ 📸❌ Both Mic and Cam are off. Disconnecting stream."); // disconnect(); // Uncomment if you want to auto-disconnect } }, [isAppMicActive, isAppCamActive, connected, disconnect]); return ( ); } export default memo(ControlTray);