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