Spaces:
Running
Running
Update src/components/control-tray/ControlTray.tsx
Browse files
src/components/control-tray/ControlTray.tsx
CHANGED
@@ -1,8 +1,8 @@
|
|
1 |
-
// src/components/control-tray/ControlTray.tsx
|
2 |
|
3 |
import cn from "classnames";
|
4 |
import React, { memo, RefObject, useEffect, useState, useCallback, useRef } from "react";
|
5 |
-
import { useAppContext } from "../../contexts/AppContext";
|
6 |
import { AudioRecorder } from "../../lib/audio-recorder";
|
7 |
import Logo from '../logo/Logo';
|
8 |
import { PauseIconWithSurroundPulse, microphoneIcon, cameraIcon, stopCamIcon } from '../icons';
|
@@ -21,7 +21,7 @@ export type ControlTrayProps = {
|
|
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);
|
27 |
const [isSwitchingCamera, setIsSwitchingCamera] = useState(false);
|
@@ -33,55 +33,34 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
33 |
audioRecorderRef.current = new AudioRecorder();
|
34 |
}
|
35 |
const audioRecorder = audioRecorderRef.current;
|
36 |
-
|
37 |
const handleAudioData = (base64: string, vol: number) => {
|
38 |
if (client && connected && isAppMicActive) {
|
39 |
-
if (base64) {
|
40 |
-
client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]);
|
41 |
-
}
|
42 |
setUserVolume(vol);
|
43 |
onUserSpeakingChange(vol > 0.01);
|
44 |
}
|
45 |
};
|
46 |
-
|
47 |
-
const onStop = () => {
|
48 |
-
setUserVolume(0);
|
49 |
-
onUserSpeakingChange(false);
|
50 |
-
}
|
51 |
-
|
52 |
if (isAppMicActive && connected) {
|
53 |
-
audioRecorder.on("data", handleAudioData);
|
54 |
-
audioRecorder.
|
55 |
-
if (!audioRecorder.recording) {
|
56 |
-
audioRecorder.start();
|
57 |
-
}
|
58 |
} else {
|
59 |
-
if (audioRecorder?.recording)
|
60 |
-
audioRecorder.stop();
|
61 |
-
}
|
62 |
}
|
63 |
-
|
64 |
-
return () => {
|
65 |
-
if (audioRecorder) {
|
66 |
-
audioRecorder.off("data", handleAudioData);
|
67 |
-
audioRecorder.off("stop", onStop);
|
68 |
-
}
|
69 |
-
};
|
70 |
}, [isAppMicActive, connected, onUserSpeakingChange, client]);
|
71 |
|
72 |
useEffect(() => {
|
73 |
-
if (videoRef.current) {
|
74 |
-
|
75 |
-
|
76 |
-
if (activeLocalVideoStream) videoRef.current.play().catch(e => console.warn("Video play failed:", e));
|
77 |
-
}
|
78 |
}
|
79 |
}, [activeLocalVideoStream, videoRef]);
|
80 |
|
81 |
const startWebcam = useCallback(async (facingModeToTry: 'user' | 'environment') => {
|
82 |
if (isSwitchingCamera) return;
|
83 |
setIsSwitchingCamera(true);
|
84 |
-
|
85 |
try {
|
86 |
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingModeToTry }, audio: false });
|
87 |
setActiveLocalVideoStream(mediaStream);
|
@@ -105,39 +84,37 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
105 |
}, [isAppCamActive, activeLocalVideoStream, isSwitchingCamera, startWebcam, currentFacingMode]);
|
106 |
|
107 |
useEffect(() => {
|
108 |
-
let timeoutId
|
109 |
function sendVideoFrame() {
|
110 |
-
if (connected && activeLocalVideoStream
|
111 |
const video = videoRef.current;
|
112 |
const canvas = renderCanvasRef.current;
|
113 |
-
if (
|
114 |
-
|
115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
116 |
}
|
117 |
-
|
118 |
-
const ctx = canvas.getContext("2d");
|
119 |
-
if (!ctx) return;
|
120 |
-
const scale = 0.5;
|
121 |
-
canvas.width = video.videoWidth * scale;
|
122 |
-
canvas.height = video.videoHeight * scale;
|
123 |
-
if (canvas.width > 0 && canvas.height > 0) {
|
124 |
-
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
125 |
-
const base64 = canvas.toDataURL("image/jpeg", 0.8);
|
126 |
-
const data = base64.slice(base64.indexOf(",") + 1);
|
127 |
-
if (data) client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]);
|
128 |
-
}
|
129 |
-
} catch (error) { console.error("❌ Error frame:", error); }
|
130 |
}
|
131 |
-
if (connected && activeLocalVideoStream) timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4);
|
132 |
}
|
133 |
-
if (connected && activeLocalVideoStream
|
134 |
return () => clearTimeout(timeoutId);
|
135 |
}, [connected, activeLocalVideoStream, client, videoRef, renderCanvasRef]);
|
136 |
|
137 |
const ensureConnectedAndReady = async (): Promise<boolean> => {
|
138 |
if (!connected) {
|
139 |
-
try { await connect(); return true; }
|
140 |
-
catch (err) { console.error('❌ CT Connect err:', err); return false; }
|
141 |
}
|
142 |
return true;
|
143 |
};
|
@@ -145,9 +122,7 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
145 |
const handleMicToggle = async () => {
|
146 |
if (isSwitchingCamera) return;
|
147 |
const newMicState = !isAppMicActive;
|
148 |
-
if (newMicState && !(await ensureConnectedAndReady())) {
|
149 |
-
onAppMicToggle(false); return;
|
150 |
-
}
|
151 |
onAppMicToggle(newMicState);
|
152 |
};
|
153 |
|
@@ -176,7 +151,7 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
176 |
} catch (error) {
|
177 |
console.error(`❌ Switch Cam err to ${targetFacingMode}:`, error);
|
178 |
try {
|
179 |
-
const restoredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: {exact: currentFacingMode } }, audio: false });
|
180 |
setActiveLocalVideoStream(restoredStream);
|
181 |
} catch (restoreError) {
|
182 |
console.error(`❌ Restore Cam err to ${currentFacingMode}:`, restoreError);
|
@@ -191,12 +166,16 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
191 |
const isSpeaking = userVolume > 0.01;
|
192 |
|
193 |
return (
|
|
|
194 |
<footer id="footer-controls" className="footer-controls-html-like">
|
195 |
<canvas style={{ display: "none" }} ref={renderCanvasRef} />
|
|
|
|
|
196 |
<div id="mic-button" className="control-button mic-button-color" onClick={handleMicToggle}>
|
197 |
{isAppMicActive ? <PauseIconWithSurroundPulse userVolume={userVolume} /> : microphoneIcon}
|
198 |
</div>
|
199 |
|
|
|
200 |
{isAppCamActive && (
|
201 |
<div id="small-logo-footer-container" className="small-logo-footer-html-like">
|
202 |
<Logo
|
@@ -209,7 +188,8 @@ const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onUserSpeakingChang
|
|
209 |
</div>
|
210 |
)}
|
211 |
|
212 |
-
|
|
|
213 |
<div id="cam-button" className="control-button cam-button-color" onClick={handleCamToggle}>
|
214 |
{isAppCamActive ? stopCamIcon : cameraIcon}
|
215 |
</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";
|
5 |
+
import { useAppContext } from "../../contexts/AppContext";
|
6 |
import { AudioRecorder } from "../../lib/audio-recorder";
|
7 |
import Logo from '../logo/Logo';
|
8 |
import { PauseIconWithSurroundPulse, microphoneIcon, cameraIcon, stopCamIcon } from '../icons';
|
|
|
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);
|
27 |
const [isSwitchingCamera, setIsSwitchingCamera] = useState(false);
|
|
|
33 |
audioRecorderRef.current = new AudioRecorder();
|
34 |
}
|
35 |
const audioRecorder = audioRecorderRef.current;
|
|
|
36 |
const handleAudioData = (base64: string, vol: number) => {
|
37 |
if (client && connected && isAppMicActive) {
|
38 |
+
if (base64) client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]);
|
|
|
|
|
39 |
setUserVolume(vol);
|
40 |
onUserSpeakingChange(vol > 0.01);
|
41 |
}
|
42 |
};
|
43 |
+
const onStop = () => { setUserVolume(0); onUserSpeakingChange(false); };
|
|
|
|
|
|
|
|
|
|
|
44 |
if (isAppMicActive && connected) {
|
45 |
+
audioRecorder.on("data", handleAudioData).on("stop", onStop);
|
46 |
+
if (!audioRecorder.recording) audioRecorder.start();
|
|
|
|
|
|
|
47 |
} else {
|
48 |
+
if (audioRecorder?.recording) audioRecorder.stop();
|
|
|
|
|
49 |
}
|
50 |
+
return () => { audioRecorder?.off("data", handleAudioData).off("stop", onStop); };
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
}, [isAppMicActive, connected, onUserSpeakingChange, client]);
|
52 |
|
53 |
useEffect(() => {
|
54 |
+
if (videoRef.current && 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 |
}, [activeLocalVideoStream, videoRef]);
|
59 |
|
60 |
const startWebcam = useCallback(async (facingModeToTry: 'user' | 'environment') => {
|
61 |
if (isSwitchingCamera) return;
|
62 |
setIsSwitchingCamera(true);
|
63 |
+
activeLocalVideoStream?.getTracks().forEach(track => track.stop());
|
64 |
try {
|
65 |
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingModeToTry }, audio: false });
|
66 |
setActiveLocalVideoStream(mediaStream);
|
|
|
84 |
}, [isAppCamActive, activeLocalVideoStream, isSwitchingCamera, startWebcam, currentFacingMode]);
|
85 |
|
86 |
useEffect(() => {
|
87 |
+
let timeoutId: number;
|
88 |
function sendVideoFrame() {
|
89 |
+
if (connected && activeLocalVideoStream) {
|
90 |
const video = videoRef.current;
|
91 |
const canvas = renderCanvasRef.current;
|
92 |
+
if (video && canvas && video.readyState >= video.HAVE_METADATA && !video.paused) {
|
93 |
+
try {
|
94 |
+
const ctx = canvas.getContext("2d");
|
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 |
+
timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; } catch (err) { console.error('❌ CT Connect err:', err); return false; }
|
|
|
118 |
}
|
119 |
return true;
|
120 |
};
|
|
|
122 |
const handleMicToggle = async () => {
|
123 |
if (isSwitchingCamera) return;
|
124 |
const newMicState = !isAppMicActive;
|
125 |
+
if (newMicState && !(await ensureConnectedAndReady())) { onAppMicToggle(false); return; }
|
|
|
|
|
126 |
onAppMicToggle(newMicState);
|
127 |
};
|
128 |
|
|
|
151 |
} catch (error) {
|
152 |
console.error(`❌ Switch Cam err to ${targetFacingMode}:`, error);
|
153 |
try {
|
154 |
+
const restoredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: currentFacingMode } }, audio: false });
|
155 |
setActiveLocalVideoStream(restoredStream);
|
156 |
} catch (restoreError) {
|
157 |
console.error(`❌ Restore Cam err to ${currentFacingMode}:`, restoreError);
|
|
|
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
|
|
|
188 |
</div>
|
189 |
)}
|
190 |
|
191 |
+
{/* نگهدارنده دکمه دوربین و تعویض دوربین در سمت راست */}
|
192 |
+
<div className="control-button-wrapper">
|
193 |
<div id="cam-button" className="control-button cam-button-color" onClick={handleCamToggle}>
|
194 |
{isAppCamActive ? stopCamIcon : cameraIcon}
|
195 |
</div>
|