|
|
|
|
|
|
|
|
import cn from "classnames"; |
|
|
import React, { memo, RefObject, useEffect, useState, useCallback, useRef } from "react"; |
|
|
import { useAppContext } from "../../contexts/AppContext"; |
|
|
import { AudioRecorder } from "../../lib/audio-recorder"; |
|
|
import Logo from '../logo/Logo'; |
|
|
import { PauseIconWithSurroundPulse, microphoneIcon, cameraIcon, stopCamIcon } from '../icons'; |
|
|
|
|
|
const SvgSwitchCameraIcon = () => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-[22px] h-[22px]"><path d="M11 19H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/><path d="M13 5h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5"/><path d="m17 12-3-3 3-3"/><path d="m7 12 3 3-3 3"/></svg>; |
|
|
|
|
|
export type ControlTrayProps = { |
|
|
videoRef: RefObject<HTMLVideoElement>; |
|
|
onUserSpeakingChange: (isSpeaking: boolean) => void; |
|
|
isAppMicActive: boolean; |
|
|
onAppMicToggle: (active: boolean) => void; |
|
|
isAppCamActive: boolean; |
|
|
onAppCamToggle: (active: boolean) => void; |
|
|
currentFacingMode: 'user' | 'environment'; |
|
|
onFacingModeChange: (mode: 'user' | 'environment') => void; |
|
|
}; |
|
|
|
|
|
const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChange, isAppMicActive, onAppMicToggle, isAppCamActive, onAppCamToggle, currentFacingMode, onFacingModeChange }) => { |
|
|
const { client, connected, connect, volume } = useAppContext(); |
|
|
const audioRecorderRef = useRef<AudioRecorder | null>(null); |
|
|
const [activeLocalVideoStream, setActiveLocalVideoStream] = useState<MediaStream | null>(null); |
|
|
const [isSwitchingCamera, setIsSwitchingCamera] = useState(false); |
|
|
const renderCanvasRef = React.useRef<HTMLCanvasElement>(null); |
|
|
const [userVolume, setUserVolume] = useState(0); |
|
|
|
|
|
useEffect(() => { |
|
|
if (!audioRecorderRef.current) { |
|
|
audioRecorderRef.current = new AudioRecorder(); |
|
|
} |
|
|
const audioRecorder = audioRecorderRef.current; |
|
|
const handleAudioData = (base64: string, vol: number) => { |
|
|
if (client && connected && isAppMicActive) { |
|
|
if (base64) client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]); |
|
|
setUserVolume(vol); |
|
|
onUserSpeakingChange(vol > 0.01); |
|
|
} |
|
|
}; |
|
|
const onStop = () => { setUserVolume(0); onUserSpeakingChange(false); }; |
|
|
if (isAppMicActive && connected) { |
|
|
audioRecorder.on("data", handleAudioData).on("stop", onStop); |
|
|
if (!audioRecorder.recording) audioRecorder.start(); |
|
|
} else { |
|
|
if (audioRecorder?.recording) audioRecorder.stop(); |
|
|
} |
|
|
return () => { audioRecorder?.off("data", handleAudioData).off("stop", onStop); }; |
|
|
}, [isAppMicActive, connected, onUserSpeakingChange, client]); |
|
|
|
|
|
useEffect(() => { |
|
|
if (videoRef.current && videoRef.current.srcObject !== activeLocalVideoStream) { |
|
|
videoRef.current.srcObject = activeLocalVideoStream; |
|
|
if (activeLocalVideoStream) videoRef.current.play().catch(e => console.warn("Video play failed:", e)); |
|
|
} |
|
|
}, [activeLocalVideoStream, videoRef]); |
|
|
|
|
|
const startWebcam = useCallback(async (facingModeToTry: 'user' | 'environment') => { |
|
|
if (isSwitchingCamera) return; |
|
|
setIsSwitchingCamera(true); |
|
|
activeLocalVideoStream?.getTracks().forEach(track => track.stop()); |
|
|
try { |
|
|
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingModeToTry }, audio: false }); |
|
|
setActiveLocalVideoStream(mediaStream); |
|
|
onFacingModeChange(facingModeToTry); |
|
|
} catch (error) { |
|
|
console.error(`❌ Start WC err ${facingModeToTry}:`, error); |
|
|
setActiveLocalVideoStream(null); |
|
|
onAppCamToggle(false); |
|
|
} finally { |
|
|
setIsSwitchingCamera(false); |
|
|
} |
|
|
}, [isSwitchingCamera, activeLocalVideoStream, onFacingModeChange, onAppCamToggle]); |
|
|
|
|
|
useEffect(() => { |
|
|
if (isAppCamActive && !activeLocalVideoStream && !isSwitchingCamera) { |
|
|
startWebcam(currentFacingMode); |
|
|
} else if (!isAppCamActive && activeLocalVideoStream) { |
|
|
activeLocalVideoStream.getTracks().forEach(track => track.stop()); |
|
|
setActiveLocalVideoStream(null); |
|
|
} |
|
|
}, [isAppCamActive, activeLocalVideoStream, isSwitchingCamera, startWebcam, currentFacingMode]); |
|
|
|
|
|
useEffect(() => { |
|
|
let timeoutId: number; |
|
|
function sendVideoFrame() { |
|
|
if (connected && activeLocalVideoStream) { |
|
|
const video = videoRef.current; |
|
|
const canvas = renderCanvasRef.current; |
|
|
if (video && canvas && video.readyState >= video.HAVE_METADATA && !video.paused) { |
|
|
try { |
|
|
const ctx = canvas.getContext("2d"); |
|
|
if (ctx) { |
|
|
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) client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]); |
|
|
} |
|
|
} |
|
|
} catch (error) { console.error("❌ Error frame:", error); } |
|
|
} |
|
|
timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4); |
|
|
} |
|
|
} |
|
|
if (connected && activeLocalVideoStream) timeoutId = window.setTimeout(sendVideoFrame, 200); |
|
|
return () => clearTimeout(timeoutId); |
|
|
}, [connected, activeLocalVideoStream, client, videoRef, renderCanvasRef]); |
|
|
|
|
|
const ensureConnectedAndReady = async (): Promise<boolean> => { |
|
|
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); |
|
|
}; |
|
|
|
|
|
const handleCamToggle = async () => { |
|
|
if (isSwitchingCamera) return; |
|
|
const newCamState = !isAppCamActive; |
|
|
if (newCamState) { |
|
|
if (!(await ensureConnectedAndReady())) { onAppCamToggle(false); return; } |
|
|
if (!isAppMicActive) onAppMicToggle(true); |
|
|
onAppCamToggle(true); |
|
|
} else { |
|
|
onAppCamToggle(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleSwitchCamera = async () => { |
|
|
if (!isAppCamActive || !activeLocalVideoStream || isSwitchingCamera) return; |
|
|
setIsSwitchingCamera(true); |
|
|
const targetFacingMode = currentFacingMode === 'user' ? 'environment' : 'user'; |
|
|
activeLocalVideoStream.getTracks().forEach(track => track.stop()); |
|
|
setActiveLocalVideoStream(null); |
|
|
try { |
|
|
const newStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: targetFacingMode } }, audio: false }); |
|
|
setActiveLocalVideoStream(newStream); |
|
|
onFacingModeChange(targetFacingMode); |
|
|
} catch (error) { |
|
|
console.error(`❌ Switch Cam err to ${targetFacingMode}:`, error); |
|
|
try { |
|
|
const restoredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: currentFacingMode } }, audio: false }); |
|
|
setActiveLocalVideoStream(restoredStream); |
|
|
} catch (restoreError) { |
|
|
console.error(`❌ Restore Cam err to ${currentFacingMode}:`, restoreError); |
|
|
setActiveLocalVideoStream(null); |
|
|
onAppCamToggle(false); |
|
|
} |
|
|
} finally { |
|
|
setIsSwitchingCamera(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
const isSpeaking = userVolume > 0.01; |
|
|
|
|
|
return ( |
|
|
|
|
|
<footer id="footer-controls" className="footer-controls-html-like"> |
|
|
<canvas style={{ display: "none" }} ref={renderCanvasRef} /> |
|
|
|
|
|
{/* دکمه میکروفون در سمت چپ */} |
|
|
<div id="mic-button" className="control-button mic-button-color" onClick={handleMicToggle}> |
|
|
{isAppMicActive ? <PauseIconWithSurroundPulse userVolume={userVolume} /> : microphoneIcon} |
|
|
</div> |
|
|
|
|
|
{/* لوگو در وسط (فقط وقتی دوربین فعال است) */} |
|
|
{isAppCamActive && ( |
|
|
<div id="small-logo-footer-container" className="small-logo-footer-html-like"> |
|
|
<Logo |
|
|
isMini={true} |
|
|
isActive={true} |
|
|
isAi={false} |
|
|
speakingVolume={volume} |
|
|
isUserSpeaking={isSpeaking} |
|
|
/> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* نگهدارنده دکمه دوربین و تعویض دوربین در سمت راست */} |
|
|
<div className="control-button-wrapper"> |
|
|
<div id="cam-button" className="control-button cam-button-color" onClick={handleCamToggle}> |
|
|
{isAppCamActive ? stopCamIcon : cameraIcon} |
|
|
</div> |
|
|
<div id="switch-camera-button-container" className={cn("switch-camera-button-container", { visible: isAppCamActive && !isSwitchingCamera })}> |
|
|
<button id="switch-camera-button" aria-label="Switch Camera" className="switch-camera-button-content group" onClick={handleSwitchCamera} disabled={!isAppCamActive || isSwitchingCamera}> |
|
|
<SvgSwitchCameraIcon/> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</footer> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default memo(ControlTray); |