/**
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);