Spaces:
Running
Running
Update src/components/control-tray/ControlTray.tsx
Browse files
src/components/control-tray/ControlTray.tsx
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
// src/components/control-tray/ControlTray.tsx (نسخه نهایی با
|
2 |
|
3 |
import cn from "classnames";
|
4 |
import React, { memo, RefObject, useEffect, useState, useCallback, useRef } from "react";
|
@@ -18,9 +18,11 @@ export type ControlTrayProps = {
|
|
18 |
onAppCamToggle: (active: boolean) => void;
|
19 |
currentFacingMode: 'user' | 'environment';
|
20 |
onFacingModeChange: (mode: 'user' | 'environment') => void;
|
|
|
|
|
21 |
};
|
22 |
|
23 |
-
const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChange, isAppMicActive, onAppMicToggle, isAppCamActive, onAppCamToggle, currentFacingMode, onFacingModeChange }) => {
|
24 |
const { client, connected, connect, volume } = useAppContext();
|
25 |
const audioRecorderRef = useRef<AudioRecorder | null>(null);
|
26 |
const [activeLocalVideoStream, setActiveLocalVideoStream] = useState<MediaStream | null>(null);
|
@@ -29,9 +31,7 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
29 |
const [userVolume, setUserVolume] = useState(0);
|
30 |
|
31 |
useEffect(() => {
|
32 |
-
if (!audioRecorderRef.current)
|
33 |
-
audioRecorderRef.current = new AudioRecorder();
|
34 |
-
}
|
35 |
const audioRecorder = audioRecorderRef.current;
|
36 |
const handleAudioData = (base64: string, vol: number) => {
|
37 |
if (client && connected && isAppMicActive) {
|
@@ -42,25 +42,26 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
42 |
};
|
43 |
const onStop = () => { setUserVolume(0); onUserSpeakingChange(false); };
|
44 |
if (isAppMicActive && connected) {
|
45 |
-
audioRecorder.on("data", handleAudioData)
|
|
|
46 |
if (!audioRecorder.recording) audioRecorder.start();
|
47 |
-
} else {
|
48 |
-
|
49 |
-
}
|
50 |
-
return () => { audioRecorder?.off("data", handleAudioData).off("stop", onStop); };
|
51 |
}, [isAppMicActive, connected, onUserSpeakingChange, client]);
|
52 |
|
53 |
useEffect(() => {
|
54 |
-
if (videoRef.current
|
55 |
-
videoRef.current.srcObject
|
56 |
-
|
|
|
|
|
57 |
}
|
58 |
}, [activeLocalVideoStream, videoRef]);
|
59 |
|
60 |
const startWebcam = useCallback(async (facingModeToTry: 'user' | 'environment') => {
|
61 |
if (isSwitchingCamera) return;
|
62 |
setIsSwitchingCamera(true);
|
63 |
-
activeLocalVideoStream
|
64 |
try {
|
65 |
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingModeToTry }, audio: false });
|
66 |
setActiveLocalVideoStream(mediaStream);
|
@@ -69,9 +70,7 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
69 |
console.error(`❌ Start WC err ${facingModeToTry}:`, error);
|
70 |
setActiveLocalVideoStream(null);
|
71 |
onAppCamToggle(false);
|
72 |
-
} finally {
|
73 |
-
setIsSwitchingCamera(false);
|
74 |
-
}
|
75 |
}, [isSwitchingCamera, activeLocalVideoStream, onFacingModeChange, onAppCamToggle]);
|
76 |
|
77 |
useEffect(() => {
|
@@ -84,62 +83,65 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
84 |
}, [isAppCamActive, activeLocalVideoStream, isSwitchingCamera, startWebcam, currentFacingMode]);
|
85 |
|
86 |
useEffect(() => {
|
87 |
-
let timeoutId
|
88 |
function sendVideoFrame() {
|
89 |
-
if (connected && activeLocalVideoStream) {
|
90 |
const video = videoRef.current;
|
91 |
const canvas = renderCanvasRef.current;
|
92 |
-
if (
|
93 |
-
|
94 |
-
|
95 |
-
if (ctx) {
|
96 |
-
const scale = 0.5;
|
97 |
-
canvas.width = video.videoWidth * scale;
|
98 |
-
canvas.height = video.videoHeight * scale;
|
99 |
-
if (canvas.width > 0 && canvas.height > 0) {
|
100 |
-
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
101 |
-
const base64 = canvas.toDataURL("image/jpeg", 0.8);
|
102 |
-
const data = base64.slice(base64.indexOf(",") + 1);
|
103 |
-
if (data && client) client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]);
|
104 |
-
}
|
105 |
-
}
|
106 |
-
} catch (error) { console.error("❌ Error frame:", error); }
|
107 |
}
|
108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
109 |
}
|
|
|
110 |
}
|
111 |
-
if (connected && activeLocalVideoStream) timeoutId = window.setTimeout(sendVideoFrame, 200);
|
112 |
return () => clearTimeout(timeoutId);
|
113 |
}, [connected, activeLocalVideoStream, client, videoRef, renderCanvasRef]);
|
114 |
|
115 |
const ensureConnectedAndReady = async (): Promise<boolean> => {
|
|
|
116 |
if (!connected) {
|
117 |
-
try { await connect(); return true; }
|
|
|
118 |
}
|
119 |
return true;
|
120 |
};
|
121 |
|
122 |
const handleMicToggle = async () => {
|
123 |
-
if (isSwitchingCamera) return;
|
124 |
const newMicState = !isAppMicActive;
|
125 |
-
if (newMicState && !(await ensureConnectedAndReady())) {
|
|
|
|
|
126 |
onAppMicToggle(newMicState);
|
127 |
};
|
128 |
|
129 |
const handleCamToggle = async () => {
|
130 |
-
if (isSwitchingCamera) return;
|
131 |
const newCamState = !isAppCamActive;
|
132 |
if (newCamState) {
|
133 |
if (!(await ensureConnectedAndReady())) { onAppCamToggle(false); return; }
|
134 |
if (!isAppMicActive) onAppMicToggle(true);
|
135 |
onAppCamToggle(true);
|
136 |
-
} else {
|
137 |
-
onAppCamToggle(false);
|
138 |
-
}
|
139 |
};
|
140 |
|
141 |
const handleSwitchCamera = async () => {
|
142 |
-
if (!isAppCamActive || !activeLocalVideoStream || isSwitchingCamera) return;
|
143 |
setIsSwitchingCamera(true);
|
144 |
const targetFacingMode = currentFacingMode === 'user' ? 'environment' : 'user';
|
145 |
activeLocalVideoStream.getTracks().forEach(track => track.stop());
|
@@ -151,50 +153,38 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
151 |
} catch (error) {
|
152 |
console.error(`❌ Switch Cam err to ${targetFacingMode}:`, error);
|
153 |
try {
|
154 |
-
const restoredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: {
|
155 |
setActiveLocalVideoStream(restoredStream);
|
156 |
} catch (restoreError) {
|
157 |
console.error(`❌ Restore Cam err to ${currentFacingMode}:`, restoreError);
|
158 |
setActiveLocalVideoStream(null);
|
159 |
onAppCamToggle(false);
|
160 |
}
|
161 |
-
} finally {
|
162 |
-
setIsSwitchingCamera(false);
|
163 |
-
}
|
164 |
};
|
165 |
|
166 |
const isSpeaking = userVolume > 0.01;
|
167 |
|
168 |
return (
|
169 |
-
// ✅ ساختار JSX به حالت صحیح اولیه برگردانده شد
|
170 |
<footer id="footer-controls" className="footer-controls-html-like">
|
171 |
<canvas style={{ display: "none" }} ref={renderCanvasRef} />
|
172 |
-
|
173 |
-
{
|
174 |
-
<div id="mic-button" className="control-button mic-button-color" onClick={handleMicToggle}>
|
175 |
{isAppMicActive ? <PauseIconWithSurroundPulse userVolume={userVolume} /> : microphoneIcon}
|
176 |
</div>
|
177 |
|
178 |
-
{/* لوگو در وسط (فقط وقتی دوربین فعال است) */}
|
179 |
{isAppCamActive && (
|
180 |
<div id="small-logo-footer-container" className="small-logo-footer-html-like">
|
181 |
-
<Logo
|
182 |
-
isMini={true}
|
183 |
-
isActive={true}
|
184 |
-
isAi={false}
|
185 |
-
speakingVolume={volume}
|
186 |
-
isUserSpeaking={isSpeaking}
|
187 |
-
/>
|
188 |
</div>
|
189 |
)}
|
190 |
|
191 |
-
|
192 |
-
|
193 |
-
<div id="cam-button" className="control-button cam-button-color" onClick={handleCamToggle}>
|
194 |
{isAppCamActive ? stopCamIcon : cameraIcon}
|
195 |
</div>
|
196 |
<div id="switch-camera-button-container" className={cn("switch-camera-button-container", { visible: isAppCamActive && !isSwitchingCamera })}>
|
197 |
-
<button id="switch-camera-button" aria-label="Switch Camera" className="switch-camera-button-content group" onClick={handleSwitchCamera} disabled={!isAppCamActive || isSwitchingCamera}>
|
198 |
<SvgSwitchCameraIcon/>
|
199 |
</button>
|
200 |
</div>
|
|
|
1 |
+
// src/components/control-tray/ControlTray.tsx (نسخه نهایی با قابلیت غیرفعال شدن)
|
2 |
|
3 |
import cn from "classnames";
|
4 |
import React, { memo, RefObject, useEffect, useState, useCallback, useRef } from "react";
|
|
|
18 |
onAppCamToggle: (active: boolean) => void;
|
19 |
currentFacingMode: 'user' | 'environment';
|
20 |
onFacingModeChange: (mode: 'user' | 'environment') => void;
|
21 |
+
// ✅ پراپرتی جدید برای غیرفعال کردن کنترلها
|
22 |
+
isTimeUp: boolean;
|
23 |
};
|
24 |
|
25 |
+
const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChange, isAppMicActive, onAppMicToggle, isAppCamActive, onAppCamToggle, currentFacingMode, onFacingModeChange, isTimeUp }) => {
|
26 |
const { client, connected, connect, volume } = useAppContext();
|
27 |
const audioRecorderRef = useRef<AudioRecorder | null>(null);
|
28 |
const [activeLocalVideoStream, setActiveLocalVideoStream] = useState<MediaStream | null>(null);
|
|
|
31 |
const [userVolume, setUserVolume] = useState(0);
|
32 |
|
33 |
useEffect(() => {
|
34 |
+
if (!audioRecorderRef.current) audioRecorderRef.current = new AudioRecorder();
|
|
|
|
|
35 |
const audioRecorder = audioRecorderRef.current;
|
36 |
const handleAudioData = (base64: string, vol: number) => {
|
37 |
if (client && connected && isAppMicActive) {
|
|
|
42 |
};
|
43 |
const onStop = () => { setUserVolume(0); onUserSpeakingChange(false); };
|
44 |
if (isAppMicActive && connected) {
|
45 |
+
audioRecorder.on("data", handleAudioData);
|
46 |
+
audioRecorder.on("stop", onStop);
|
47 |
if (!audioRecorder.recording) audioRecorder.start();
|
48 |
+
} else if (audioRecorder?.recording) { audioRecorder.stop(); }
|
49 |
+
return () => { if (audioRecorder) { audioRecorder.off("data", handleAudioData); audioRecorder.off("stop", onStop); } };
|
|
|
|
|
50 |
}, [isAppMicActive, connected, onUserSpeakingChange, client]);
|
51 |
|
52 |
useEffect(() => {
|
53 |
+
if (videoRef.current) {
|
54 |
+
if (videoRef.current.srcObject !== activeLocalVideoStream) {
|
55 |
+
videoRef.current.srcObject = activeLocalVideoStream;
|
56 |
+
if (activeLocalVideoStream) videoRef.current.play().catch(e => console.warn("Video play failed:", e));
|
57 |
+
}
|
58 |
}
|
59 |
}, [activeLocalVideoStream, videoRef]);
|
60 |
|
61 |
const startWebcam = useCallback(async (facingModeToTry: 'user' | 'environment') => {
|
62 |
if (isSwitchingCamera) return;
|
63 |
setIsSwitchingCamera(true);
|
64 |
+
if (activeLocalVideoStream) activeLocalVideoStream.getTracks().forEach(track => track.stop());
|
65 |
try {
|
66 |
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingModeToTry }, audio: false });
|
67 |
setActiveLocalVideoStream(mediaStream);
|
|
|
70 |
console.error(`❌ Start WC err ${facingModeToTry}:`, error);
|
71 |
setActiveLocalVideoStream(null);
|
72 |
onAppCamToggle(false);
|
73 |
+
} finally { setIsSwitchingCamera(false); }
|
|
|
|
|
74 |
}, [isSwitchingCamera, activeLocalVideoStream, onFacingModeChange, onAppCamToggle]);
|
75 |
|
76 |
useEffect(() => {
|
|
|
83 |
}, [isAppCamActive, activeLocalVideoStream, isSwitchingCamera, startWebcam, currentFacingMode]);
|
84 |
|
85 |
useEffect(() => {
|
86 |
+
let timeoutId = -1;
|
87 |
function sendVideoFrame() {
|
88 |
+
if (connected && activeLocalVideoStream && client && videoRef.current) {
|
89 |
const video = videoRef.current;
|
90 |
const canvas = renderCanvasRef.current;
|
91 |
+
if (!canvas || video.readyState < video.HAVE_METADATA || video.paused || video.ended) {
|
92 |
+
if (activeLocalVideoStream) timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4);
|
93 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
94 |
}
|
95 |
+
try {
|
96 |
+
const ctx = canvas.getContext("2d");
|
97 |
+
if (!ctx) return;
|
98 |
+
const scale = 0.5;
|
99 |
+
canvas.width = video.videoWidth * scale;
|
100 |
+
canvas.height = video.videoHeight * scale;
|
101 |
+
if (canvas.width > 0 && canvas.height > 0) {
|
102 |
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
103 |
+
const base64 = canvas.toDataURL("image/jpeg", 0.8);
|
104 |
+
const data = base64.slice(base64.indexOf(",") + 1);
|
105 |
+
if (data) client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]);
|
106 |
+
}
|
107 |
+
} catch (error) { console.error("❌ Error frame:", error); }
|
108 |
}
|
109 |
+
if (connected && activeLocalVideoStream) timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4);
|
110 |
}
|
111 |
+
if (connected && activeLocalVideoStream && videoRef.current) timeoutId = window.setTimeout(sendVideoFrame, 200);
|
112 |
return () => clearTimeout(timeoutId);
|
113 |
}, [connected, activeLocalVideoStream, client, videoRef, renderCanvasRef]);
|
114 |
|
115 |
const ensureConnectedAndReady = async (): Promise<boolean> => {
|
116 |
+
if (isTimeUp) return false; // اگر زمان تمام شده، اجازه اتصال نده
|
117 |
if (!connected) {
|
118 |
+
try { await connect(); return true; }
|
119 |
+
catch (err) { console.error('❌ CT Connect err:', err); return false; }
|
120 |
}
|
121 |
return true;
|
122 |
};
|
123 |
|
124 |
const handleMicToggle = async () => {
|
125 |
+
if (isSwitchingCamera || isTimeUp) return;
|
126 |
const newMicState = !isAppMicActive;
|
127 |
+
if (newMicState && !(await ensureConnectedAndReady())) {
|
128 |
+
onAppMicToggle(false); return;
|
129 |
+
}
|
130 |
onAppMicToggle(newMicState);
|
131 |
};
|
132 |
|
133 |
const handleCamToggle = async () => {
|
134 |
+
if (isSwitchingCamera || isTimeUp) return;
|
135 |
const newCamState = !isAppCamActive;
|
136 |
if (newCamState) {
|
137 |
if (!(await ensureConnectedAndReady())) { onAppCamToggle(false); return; }
|
138 |
if (!isAppMicActive) onAppMicToggle(true);
|
139 |
onAppCamToggle(true);
|
140 |
+
} else { onAppCamToggle(false); }
|
|
|
|
|
141 |
};
|
142 |
|
143 |
const handleSwitchCamera = async () => {
|
144 |
+
if (!isAppCamActive || !activeLocalVideoStream || isSwitchingCamera || isTimeUp) return;
|
145 |
setIsSwitchingCamera(true);
|
146 |
const targetFacingMode = currentFacingMode === 'user' ? 'environment' : 'user';
|
147 |
activeLocalVideoStream.getTracks().forEach(track => track.stop());
|
|
|
153 |
} catch (error) {
|
154 |
console.error(`❌ Switch Cam err to ${targetFacingMode}:`, error);
|
155 |
try {
|
156 |
+
const restoredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: {exact: currentFacingMode } }, audio: false });
|
157 |
setActiveLocalVideoStream(restoredStream);
|
158 |
} catch (restoreError) {
|
159 |
console.error(`❌ Restore Cam err to ${currentFacingMode}:`, restoreError);
|
160 |
setActiveLocalVideoStream(null);
|
161 |
onAppCamToggle(false);
|
162 |
}
|
163 |
+
} finally { setIsSwitchingCamera(false); }
|
|
|
|
|
164 |
};
|
165 |
|
166 |
const isSpeaking = userVolume > 0.01;
|
167 |
|
168 |
return (
|
|
|
169 |
<footer id="footer-controls" className="footer-controls-html-like">
|
170 |
<canvas style={{ display: "none" }} ref={renderCanvasRef} />
|
171 |
+
{/* ✅ غیرفعال کردن دکمهها وقتی زمان تمام شده */}
|
172 |
+
<div id="mic-button" className={cn("control-button mic-button-color", { "disabled": isTimeUp })} onClick={handleMicToggle}>
|
|
|
173 |
{isAppMicActive ? <PauseIconWithSurroundPulse userVolume={userVolume} /> : microphoneIcon}
|
174 |
</div>
|
175 |
|
|
|
176 |
{isAppCamActive && (
|
177 |
<div id="small-logo-footer-container" className="small-logo-footer-html-like">
|
178 |
+
<Logo isMini={true} isActive={true} isAi={false} speakingVolume={volume} isUserSpeaking={isSpeaking}/>
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
</div>
|
180 |
)}
|
181 |
|
182 |
+
<div id="cam-button-wrapper" className="control-button-wrapper">
|
183 |
+
<div id="cam-button" className={cn("control-button cam-button-color", { "disabled": isTimeUp })} onClick={handleCamToggle}>
|
|
|
184 |
{isAppCamActive ? stopCamIcon : cameraIcon}
|
185 |
</div>
|
186 |
<div id="switch-camera-button-container" className={cn("switch-camera-button-container", { visible: isAppCamActive && !isSwitchingCamera })}>
|
187 |
+
<button id="switch-camera-button" aria-label="Switch Camera" className="switch-camera-button-content group" onClick={handleSwitchCamera} disabled={!isAppCamActive || isSwitchingCamera || isTimeUp}>
|
188 |
<SvgSwitchCameraIcon/>
|
189 |
</button>
|
190 |
</div>
|