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