Spaces:
Running
Running
/** | |
* Copyright 2024 Google LLC | |
* ... (لایسنس و توضیحات دیگر مثل قبل) ... | |
*/ | |
import cn from "classnames"; | |
import { memo, ReactNode, RefObject, useEffect, useRef, useState } from "react"; | |
import { useLiveAPIContext } from "../../contexts/LiveAPIContext"; | |
import { UseMediaStreamResult } from "../../hooks/use-media-stream-mux"; | |
import { useScreenCapture } from "../../hooks/use-screen-capture"; | |
import { useWebcam } from "../../hooks/use-webcam"; | |
import { AudioRecorder } from "../../lib/audio-recorder"; | |
import { isIOS } from "../../lib/platform"; | |
import AudioPulse from "../audio-pulse/AudioPulse"; | |
import "./control-tray.scss"; | |
export type ControlTrayProps = { | |
videoRef: RefObject<HTMLVideoElement>; | |
children?: ReactNode; | |
supportsVideo: boolean; | |
onVideoStreamChange?: (stream: MediaStream | null) => void; | |
}; | |
type MediaStreamButtonProps = { | |
isStreaming: boolean; | |
onIcon: string; | |
offIcon: string; | |
start: () => Promise<any>; | |
stop: () => any; | |
disabled?: boolean; | |
}; | |
const MediaStreamButton = memo( | |
({ isStreaming, onIcon, offIcon, start, stop, disabled }: MediaStreamButtonProps) => | |
isStreaming ? ( | |
<button className="action-button" onClick={stop} title={`Stop ${offIcon}`} disabled={disabled}> | |
<span className="material-symbols-outlined">{onIcon}</span> | |
</button> | |
) : ( | |
<button className="action-button" onClick={start} title={`Start ${offIcon}`} disabled={disabled}> | |
<span className="material-symbols-outlined">{offIcon}</span> | |
</button> | |
), | |
); | |
function ControlTray({ | |
videoRef, | |
children, | |
onVideoStreamChange = () => {}, | |
supportsVideo, | |
}: ControlTrayProps) { | |
const webcam = useWebcam(); | |
const screenCapture = useScreenCapture(); | |
const [activeVideoStream, setActiveVideoStream] = useState<MediaStream | null>(null); | |
const [currentFacingMode, setCurrentFacingMode] = useState<'user' | 'environment' | null>(null); | |
const [isSwitchingCamera, setIsSwitchingCamera] = useState(false); | |
const [isLikelyDesktop, setIsLikelyDesktop] = useState(false); // <-- State برای تشخیص دسکتاپ | |
const [inVolume, setInVolume] = useState(0); | |
const [audioRecorder] = useState(() => new AudioRecorder()); | |
const [muted, setMuted] = useState(false); | |
const renderCanvasRef = useRef<HTMLCanvasElement>(null); | |
const connectButtonRef = useRef<HTMLButtonElement>(null); | |
const [simulatedVolume, setSimulatedVolume] = useState(0); | |
const isIOSDevice = isIOS(); | |
const { client, connected, connect, disconnect, volume } = useLiveAPIContext(); | |
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); | |
// --- useEffect ها --- | |
// بررسی نوع دستگاه (دسکتاپ یا موبایل/تبلت) در زمان Mount | |
useEffect(() => { | |
// navigator.maxTouchPoints > 0 معمولا نشاندهنده دستگاه لمسی است | |
const desktopCheck = typeof navigator !== 'undefined' && navigator.maxTouchPoints <= 0; | |
setIsLikelyDesktop(desktopCheck); | |
console.log(`Device check: Likely Desktop? ${desktopCheck} (maxTouchPoints: ${navigator.maxTouchPoints})`); | |
}, []); // فقط یک بار در زمان Mount اجرا شود | |
// Focus effect | |
useEffect(() => { | |
if (!connected && connectButtonRef.current) { | |
connectButtonRef.current.focus(); | |
} | |
}, [connected]); | |
// iOS volume simulation | |
useEffect(() => { | |
let interval: number | undefined; | |
if (isIOSDevice && connected && !muted) { | |
interval = window.setInterval(() => { | |
const pulse = (Math.sin(Date.now() / 500) + 1) / 2; | |
setSimulatedVolume(0.02 + pulse * 0.03); | |
}, 50); | |
} | |
return () => { | |
if (interval) clearInterval(interval); | |
}; | |
}, [connected, muted, isIOSDevice]); | |
// CSS volume update | |
useEffect(() => { | |
document.documentElement.style.setProperty( | |
"--volume", | |
`${Math.max(5, Math.min((isIOSDevice ? simulatedVolume : inVolume) * 200, 8))}px`, | |
); | |
}, [inVolume, simulatedVolume, isIOSDevice]); | |
// Audio recording | |
useEffect(() => { | |
const onData = (base64: string) => { | |
if (client && connected) { | |
client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]); | |
} | |
}; | |
if (connected && !muted && audioRecorder) { | |
audioRecorder.on("data", onData).on("volume", setInVolume).start(); | |
} else if (audioRecorder) { | |
audioRecorder.stop(); | |
} | |
return () => { | |
if (audioRecorder) { | |
audioRecorder.off("data", onData).off("volume", setInVolume).stop(); | |
} | |
}; | |
}, [connected, client, muted, audioRecorder]); | |
// Stop video on disconnect | |
useEffect(() => { | |
if (!connected && activeVideoStream) { | |
console.log('🔌 Disconnected, stopping video stream.'); | |
activeVideoStream.getTracks().forEach(track => track.stop()); | |
setActiveVideoStream(null); | |
onVideoStreamChange(null); | |
setCurrentFacingMode(null); | |
setIsSwitchingCamera(false); | |
webcam.stop(); | |
screenCapture.stop(); | |
} | |
}, [connected, activeVideoStream, onVideoStreamChange, webcam, screenCapture]); | |
// Video frame sending | |
useEffect(() => { | |
let timeoutId = -1; | |
function sendVideoFrame() { | |
if (connected && activeVideoStream) { | |
timeoutId = window.setTimeout(sendVideoFrame, 1000 / 0.5); | |
} | |
const video = videoRef.current; const canvas = renderCanvasRef.current; | |
if (!video || !canvas || video.readyState < video.HAVE_METADATA || video.paused || video.ended || !client) return; | |
try { | |
const ctx = canvas.getContext("2d"); if (!ctx) return; | |
const scale = 0.25; 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); | |
client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]); | |
} | |
} catch (error) { console.error("❌ Error processing video frame:", error); } | |
} | |
if (connected && activeVideoStream && videoRef.current) { setTimeout(sendVideoFrame, 200); } | |
return () => { clearTimeout(timeoutId); }; | |
}, [connected, activeVideoStream, client, videoRef]); | |
// Assign stream to video element | |
useEffect(() => { | |
if (videoRef.current) { | |
if (videoRef.current.srcObject !== activeVideoStream) { | |
videoRef.current.srcObject = activeVideoStream; | |
if (activeVideoStream) { videoRef.current.play().catch(e => console.warn("Video play failed:", e)); } | |
} | |
} | |
}, [activeVideoStream, videoRef]); | |
// --- پایان useEffect ها --- | |
// Function to stop all video streams | |
const stopAllVideoStreams = () => { | |
console.log('⏹️ Stopping all video streams...'); | |
if (activeVideoStream) { | |
activeVideoStream.getTracks().forEach(track => track.stop()); | |
setActiveVideoStream(null); | |
onVideoStreamChange(null); | |
} | |
webcam.stop(); screenCapture.stop(); setCurrentFacingMode(null); setIsSwitchingCamera(false); | |
}; | |
// Handler for starting/stopping webcam or screen share | |
const changeStreams = (streamType: 'webcam' | 'screen' | 'none') => async () => { | |
if (isSwitchingCamera) return; | |
// Only allow starting screen share if it's likely a desktop | |
if (streamType === 'screen' && !isLikelyDesktop) { | |
console.warn("Screen share requested on non-desktop device, ignoring."); | |
return; | |
} | |
stopAllVideoStreams(); | |
if (streamType === 'webcam') { | |
const initialFacingMode = 'user'; console.log(`🚀 Starting webcam with initial facingMode: ${initialFacingMode}`); | |
try { | |
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: initialFacingMode }, audio: false }); | |
setActiveVideoStream(mediaStream); onVideoStreamChange(mediaStream); setCurrentFacingMode(initialFacingMode); | |
} catch (error) { | |
console.error(`❌ Error starting webcam with ${initialFacingMode}:`, error); | |
try { // Fallback | |
const fallbackStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false }); | |
setActiveVideoStream(fallbackStream); onVideoStreamChange(fallbackStream); setCurrentFacingMode('environment'); | |
} catch (fallbackError) { console.error('❌ Error starting webcam fallback:', fallbackError); setActiveVideoStream(null); onVideoStreamChange(null); setCurrentFacingMode(null); } | |
} | |
} else if (streamType === 'screen' && isLikelyDesktop) { // Double check desktop condition | |
console.log('🚀 Starting screen capture'); | |
try { | |
const mediaStream = await screenCapture.start(); | |
setActiveVideoStream(mediaStream); onVideoStreamChange(mediaStream); setCurrentFacingMode(null); | |
} catch (error) { console.error('❌ Error starting screen capture:', error); setActiveVideoStream(null); onVideoStreamChange(null); setCurrentFacingMode(null); } | |
} else { console.log('ℹ️ Video stream turned off or invalid request.'); } | |
}; | |
// Handler for rotating webcam | |
const rotateWebcam = async () => { | |
if (isSwitchingCamera || !activeVideoStream || currentFacingMode === null) return; | |
const targetFacingMode = currentFacingMode === 'user' ? 'environment' : 'user'; | |
console.log(`🔄 Rotating webcam... Target: ${targetFacingMode}`); | |
setIsSwitchingCamera(true); | |
activeVideoStream.getTracks().forEach(track => track.stop()); // Stop tracks only | |
try { | |
const newStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: targetFacingMode } }, audio: false }); | |
if (videoRef.current) { videoRef.current.srcObject = newStream; videoRef.current.play().catch(e => console.warn("Play fail switch:", e)); } | |
setActiveVideoStream(newStream); onVideoStreamChange(newStream); setCurrentFacingMode(targetFacingMode); | |
} catch (error: any) { | |
console.error(`❌ Error switching camera:`, error.name); | |
let recoveredStream: MediaStream | null = null; // Fallback logic... | |
if (error.name === 'OverconstrainedError' || error.name === 'ConstraintNotSatisfiedError') { | |
try { recoveredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: targetFacingMode }, audio: false }); setCurrentFacingMode(targetFacingMode); } catch (retryError: any) { console.error(`Retry fail:`, retryError.name); } | |
} | |
if (!recoveredStream) { try { recoveredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: currentFacingMode } }, audio: false }); } catch (restoreError) { console.error(`Restore fail:`, restoreError); } } | |
if (recoveredStream) { if (videoRef.current) { videoRef.current.srcObject = recoveredStream; videoRef.current.play().catch(e => console.warn("Play fail recovery:", e)); } setActiveVideoStream(recoveredStream); onVideoStreamChange(recoveredStream); } | |
else { if (videoRef.current) videoRef.current.srcObject = null; setActiveVideoStream(null); onVideoStreamChange(null); setCurrentFacingMode(null); } | |
} finally { | |
setIsSwitchingCamera(false); | |
} | |
}; | |
// Determine stable streaming states | |
const isWebcamStableStreaming = activeVideoStream !== null && currentFacingMode !== null; | |
const isScreenCaptureStreaming = screenCapture.isStreaming && activeVideoStream !== null && currentFacingMode === null; | |
// Determine if the webcam button should appear active | |
const showWebcamAsActive = isWebcamStableStreaming || (isSwitchingCamera && currentFacingMode !== null); | |
// **** Condition to show Screen Share Button **** | |
const showScreenShareButton = supportsVideo && !isIOSDevice && isLikelyDesktop; | |
return ( | |
<section className="control-tray"> | |
<canvas style={{ display: "none" }} ref={renderCanvasRef} /> | |
<nav className={cn("actions-nav", { disabled: !connected })}> | |
{/* Mic Button */} | |
<button | |
className={cn("action-button mic-button")} | |
onClick={() => setMuted(!muted)} | |
disabled={!connected || isSwitchingCamera} | |
title={muted ? "Unmute Microphone" : "Mute Microphone"} | |
> | |
<span className="material-symbols-outlined filled">{muted ? "mic_off" : "mic"}</span> | |
</button> | |
{/* Volume Indicator */} | |
<div className="action-button no-action outlined"> | |
<AudioPulse volume={volume} active={connected && !muted} hover={false} /> | |
</div> | |
{/* Video Controls */} | |
{supportsVideo && ( | |
<> | |
{/* Screen Share Button (Show only on non-iOS Desktop-like devices) */} | |
{showScreenShareButton && ( // <-- شرط جدید اینجا اعمال شده | |
<MediaStreamButton | |
isStreaming={isScreenCaptureStreaming} | |
start={changeStreams('screen')} | |
stop={changeStreams('none')} | |
onIcon="cancel_presentation" | |
offIcon="present_to_all" | |
// Disable screen share button also if webcam is active or switching? Your choice. | |
// disabled={!connected || isSwitchingCamera || showWebcamAsActive} | |
disabled={!connected || isSwitchingCamera } // Kept simpler for now | |
/> | |
)} | |
{/* Switch Camera Button */} | |
{ (isWebcamStableStreaming || isSwitchingCamera) && ( | |
<button | |
className="action-button" | |
onClick={rotateWebcam} | |
title="Switch camera" | |
disabled={!connected || isSwitchingCamera} | |
> | |
<span className="material-symbols-outlined">switch_camera</span> | |
</button> | |
)} | |
{/* Webcam On/Off Button */} | |
<MediaStreamButton | |
isStreaming={showWebcamAsActive} | |
start={changeStreams('webcam')} | |
stop={changeStreams('none')} | |
onIcon="videocam_off" | |
offIcon="videocam" | |
disabled={!connected || isSwitchingCamera} | |
/> | |
</> | |
)} | |
{children} | |
</nav> | |
{/* Connection Controls */} | |
<div className={cn("connection-container", { connected })}> | |
<div className="connection-button-container"> | |
<button | |
ref={connectButtonRef} | |
className={cn("action-button connect-toggle", { connected })} | |
onClick={async () => { | |
if (isSwitchingCamera) return; | |
try { if (connected) { await disconnect(); } else { await connect(); } } | |
catch (err) { console.error('❌ Connection/Disconnection error:', err); } | |
}} | |
disabled={isSwitchingCamera} | |
title={connected ? "Disconnect Stream" : "Connect Stream"} | |
> | |
<span className="material-symbols-outlined filled">{connected ? "pause" : "play_arrow"}</span> | |
</button> | |
</div> | |
<span className="text-indicator">{connected ? "Streaming" : "Paused"}</span> | |
</div> | |
</section> | |
); | |
} | |
export default memo(ControlTray); |