/** Copyright 2024 Google LLC ... (لایسنس و توضیحات دیگر مثل قبل) ... */ import cn from "classnames"; import React, { memo, RefObject, useEffect, useRef, useState, useCallback } from "react"; import { useLiveAPIContext } from "../../contexts/LiveAPIContext"; import { useScreenCapture } from "../../hooks/use-screen-capture"; // Keep if screen share is desired import { AudioRecorder } from "../../lib/audio-recorder"; import { isIOS } from "../../lib/platform"; // import AudioPulse from "../audio-pulse/AudioPulse"; // Original pulse, we'll use CSS for mic // Make sure your App.scss or equivalent has the .mic-button-wrapper styles // SVG Icons for ControlTray const PauseIcon = () => ; const MicrophoneIcon = () => ; const CameraIcon = () => ; const StopCamIcon = () => ; const SwitchCameraIcon = () => ; const HumanIconSVGSmall = ({ width = 35, height = 35 }: { width?: number, height?: number }) => ( ); const SmallLogoDisplay = ({ isHumanActive }: { isHumanActive: boolean }) => { if (!isHumanActive) return null; const size = 80; const iconSize = 35; const insetBase = { ping: 10, outer: 0, mid: 5, inner: 12, icon: 22.5 }; const bgColorBase = 'blue'; return (
); }; export type ControlTrayProps = { videoRef: RefObject; onVideoStreamChange: (stream: MediaStream | null) => void; setIsMicActive: (isActive: boolean) => void; // To inform App.tsx setIsCamActiveExternal: (isActive: boolean) => void; // To inform App.tsx isCamActiveApp: boolean; // Cam state from App.tsx }; function ControlTray({ videoRef, onVideoStreamChange, setIsMicActive: setIsMicActiveApp, setIsCamActiveExternal: setIsCamActiveAppExternal, isCamActiveApp, }: ControlTrayProps) { const { client, connected, connect, disconnect } = useLiveAPIContext(); const screenCapture = useScreenCapture(); // Retain for potential screen share const [audioRecorder] = useState(() => new AudioRecorder()); const [inVolume, setInVolume] = useState(0); // For mic pulse animation const [isMicEnabled, setIsMicEnabled] = useState(false); // Internal: is mic supposed to be on const [isCamEnabled, setIsCamEnabled] = useState(false); // Internal: is cam supposed to be on const [activeLocalVideoStream, setActiveLocalVideoStream] = useState(null); const [currentFacingMode, setCurrentFacingMode] = useState<'user' | 'environment'>('user'); const [isSwitchingCamera, setIsSwitchingCamera] = useState(false); const renderCanvasRef = useRef(null); // Sync internal mic/cam enabled state with App's state (via props) useEffect(() => { setIsMicActiveApp(isMicEnabled && connected); }, [isMicEnabled, connected, setIsMicActiveApp]); useEffect(() => { setIsCamActiveAppExternal(isCamEnabled && !!activeLocalVideoStream); }, [isCamEnabled, activeLocalVideoStream, setIsCamActiveAppExternal]); // Connect/Disconnect based on mic/cam state useEffect(() => { const shouldBeConnected = isMicEnabled || isCamEnabled; if (shouldBeConnected && !connected) { connect().catch(err => console.error("Failed to connect:", err)); } else if (!shouldBeConnected && connected) { disconnect().catch(err => console.error("Failed to disconnect:", err)); } }, [isMicEnabled, isCamEnabled, connected, connect, disconnect]); // Audio recording useEffect(() => { const onData = (base64: string) => { if (client && connected && isMicEnabled) { // Send only if mic is intended to be active client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]); } }; if (connected && isMicEnabled && audioRecorder) { audioRecorder.on("data", onData).on("volume", setInVolume).start(); } else if (audioRecorder) { audioRecorder.stop(); setInVolume(0); // Reset volume when stopped } return () => { if (audioRecorder) { audioRecorder.off("data", onData).off("volume", setInVolume).stop(); } }; }, [connected, client, isMicEnabled, audioRecorder]); // Update CSS --volume for mic pulse useEffect(() => { document.documentElement.style.setProperty( "--volume", `${Math.max(5, Math.min(inVolume * 200, 15))}px` // Adjusted max for new design ); }, [inVolume]); // Video frame sending useEffect(() => { let frameTimeoutId = -1; function sendVideoFrame() { if (connected && isCamEnabled && activeLocalVideoStream && client) { frameTimeoutId = window.setTimeout(sendVideoFrame, 1000 / 2); // Approx 2 FPS const video = videoRef.current; const canvas = renderCanvasRef.current; if (!video || !canvas || video.readyState < video.HAVE_METADATA || video.paused || video.ended) return; try { const ctx = canvas.getContext("2d"); if (!ctx) return; const scale = 0.25; // Lower scale for performance 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.7); // Adjust quality const data = base64.slice(base64.indexOf(",") + 1); client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]); } } catch (error) { console.error("Error processing video frame:", error); } } } if (connected && isCamEnabled && activeLocalVideoStream) { frameTimeoutId = window.setTimeout(sendVideoFrame, 200); // Initial delay } return () => { clearTimeout(frameTimeoutId); }; }, [connected, client, isCamEnabled, activeLocalVideoStream, videoRef, renderCanvasRef]); const stopAllVideo = useCallback(() => { if (activeLocalVideoStream) { activeLocalVideoStream.getTracks().forEach(track => track.stop()); setActiveLocalVideoStream(null); } onVideoStreamChange(null); // Inform App.tsx }, [activeLocalVideoStream, onVideoStreamChange]); // Start/Stop Webcam const toggleWebcam = async () => { if (isSwitchingCamera) return; if (isCamEnabled) { // Turning off setIsCamEnabled(false); stopAllVideo(); } else { // Turning on setIsCamEnabled(true); try { const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: currentFacingMode }, audio: false // Audio is handled by AudioRecorder }); setActiveLocalVideoStream(mediaStream); onVideoStreamChange(mediaStream); // Inform App.tsx } catch (error) { console.error(`Error starting webcam with ${currentFacingMode}:`, error); setIsCamEnabled(false); // Revert state on error // Try fallback if OverconstrainedError (e.g., exact facingMode not available) if ((error as Error).name === 'OverconstrainedError' || (error as Error).name === 'NotAllowedError' || (error as Error).name === 'NotFoundError') { try { const fallbackMode = currentFacingMode === 'user' ? 'environment' : 'user'; console.log(`Attempting fallback to ${fallbackMode} camera`); const fallbackStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: fallbackMode }, audio: false, }); setActiveLocalVideoStream(fallbackStream); onVideoStreamChange(fallbackStream); setCurrentFacingMode(fallbackMode); setIsCamEnabled(true); // Set true on successful fallback } catch (fallbackError) { console.error('Webcam fallback also failed:', fallbackError); onVideoStreamChange(null); } } else { onVideoStreamChange(null); } } } }; const rotateWebcam = async () => { if (isSwitchingCamera || !isCamEnabled || !activeLocalVideoStream) return; setIsSwitchingCamera(true); const targetFacingMode = currentFacingMode === 'user' ? 'environment' : 'user'; // Stop current tracks before requesting new ones activeLocalVideoStream.getTracks().forEach(track => track.stop()); try { console.log(`Switching camera to: ${targetFacingMode}`); const newStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: targetFacingMode } }, audio: false }); setActiveLocalVideoStream(newStream); onVideoStreamChange(newStream); setCurrentFacingMode(targetFacingMode); } catch (error: any) { console.error(`Error switching camera to ${targetFacingMode}: ${error.name}`, error); // Attempt to recover with the previous facing mode or any available camera try { console.log(`Fallback: trying previous mode ${currentFacingMode} or any video`); const recoveryStream = await navigator.mediaDevices.getUserMedia({ video: true, // Try with non-specific facing mode audio: false }); setActiveLocalVideoStream(recoveryStream); onVideoStreamChange(recoveryStream); // Try to determine the facing mode of the recovery stream if possible, or reset const settings = recoveryStream.getVideoTracks()[0]?.getSettings(); if (settings?.facingMode) { setCurrentFacingMode(settings.facingMode as 'user' | 'environment'); } } catch (recoveryError) { console.error('Camera recovery failed:', recoveryError); setIsCamEnabled(false); // Disable cam if all attempts fail stopAllVideo(); } } finally { setIsSwitchingCamera(false); } }; // Cleanup on unmount or when connection drops externally useEffect(() => { return () => { stopAllVideo(); if (audioRecorder) audioRecorder.stop(); }; }, [stopAllVideo, audioRecorder]); useEffect(() => { if (!connected) { // If connection drops for any reason setIsMicEnabled(false); // Assume mic should be off // Optionally, also turn off camera if connection is lost // if (isCamEnabled) { // toggleWebcam(); // This will set isCamEnabled to false and stop video // } } }, [connected]); return ( <> ); } export default memo(ControlTray);