Spaces:
Running
Running
Update src/components/control-tray/ControlTray.tsx
Browse files
src/components/control-tray/ControlTray.tsx
CHANGED
@@ -1,11 +1,10 @@
|
|
1 |
// src/components/control-tray/ControlTray.tsx
|
2 |
|
3 |
import cn from "classnames";
|
4 |
-
import React, { memo, RefObject, useEffect, useState, useCallback } from "react";
|
5 |
import { useLiveAPIContext } from "../../contexts/LiveAPIContext";
|
6 |
import { AudioRecorder } from "../../lib/audio-recorder";
|
7 |
-
import
|
8 |
-
import { pauseIcon, microphoneIcon, cameraIcon, stopCamIcon } from '../icons';
|
9 |
|
10 |
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>;
|
11 |
|
@@ -19,40 +18,59 @@ export type ControlTrayProps = {
|
|
19 |
onAppCamToggle: (active: boolean) => void;
|
20 |
currentFacingMode: 'user' | 'environment';
|
21 |
onFacingModeChange: (mode: 'user' | 'environment') => void;
|
|
|
22 |
};
|
23 |
|
24 |
-
const ControlTray: React.FC<ControlTrayProps> = ({
|
25 |
-
|
26 |
-
|
27 |
-
supportsVideo,
|
28 |
-
isAppMicActive,
|
29 |
-
onAppMicToggle,
|
30 |
-
isAppCamActive,
|
31 |
-
onAppCamToggle,
|
32 |
-
currentFacingMode,
|
33 |
-
onFacingModeChange,
|
34 |
-
}) => {
|
35 |
-
const { client, connected, connect, volume } = useLiveAPIContext();
|
36 |
-
const [audioRecorder] = useState(() => new AudioRecorder());
|
37 |
const [activeLocalVideoStream, setActiveLocalVideoStream] = useState<MediaStream | null>(null);
|
38 |
const [isSwitchingCamera, setIsSwitchingCamera] = useState(false);
|
39 |
const renderCanvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
|
40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
useEffect(() => {
|
42 |
if (videoRef.current) {
|
43 |
if (videoRef.current.srcObject !== activeLocalVideoStream) {
|
44 |
videoRef.current.srcObject = activeLocalVideoStream;
|
45 |
-
if (activeLocalVideoStream)
|
46 |
-
videoRef.current.play().catch(e => console.warn("Video play failed:", e));
|
47 |
-
}
|
48 |
}
|
49 |
}
|
50 |
}, [activeLocalVideoStream, videoRef]);
|
51 |
|
52 |
const stopWebcam = useCallback(() => {
|
53 |
-
if (activeLocalVideoStream)
|
54 |
-
activeLocalVideoStream.getTracks().forEach(track => track.stop());
|
55 |
-
}
|
56 |
setActiveLocalVideoStream(null);
|
57 |
onVideoStreamChange(null);
|
58 |
}, [activeLocalVideoStream, onVideoStreamChange]);
|
@@ -60,11 +78,7 @@ const ControlTray: React.FC<ControlTrayProps> = ({
|
|
60 |
const startWebcam = useCallback(async (facingModeToTry: 'user' | 'environment') => {
|
61 |
if (isSwitchingCamera) return;
|
62 |
setIsSwitchingCamera(true);
|
63 |
-
|
64 |
-
if (activeLocalVideoStream) {
|
65 |
-
activeLocalVideoStream.getTracks().forEach(track => track.stop());
|
66 |
-
}
|
67 |
-
|
68 |
try {
|
69 |
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingModeToTry }, audio: false });
|
70 |
setActiveLocalVideoStream(mediaStream);
|
@@ -78,13 +92,7 @@ const ControlTray: React.FC<ControlTrayProps> = ({
|
|
78 |
} finally {
|
79 |
setIsSwitchingCamera(false);
|
80 |
}
|
81 |
-
}, [
|
82 |
-
isSwitchingCamera,
|
83 |
-
activeLocalVideoStream,
|
84 |
-
onVideoStreamChange,
|
85 |
-
onFacingModeChange,
|
86 |
-
onAppCamToggle,
|
87 |
-
]);
|
88 |
|
89 |
useEffect(() => {
|
90 |
if (isAppCamActive && !activeLocalVideoStream && !isSwitchingCamera) {
|
@@ -94,26 +102,6 @@ const ControlTray: React.FC<ControlTrayProps> = ({
|
|
94 |
}
|
95 |
}, [isAppCamActive, activeLocalVideoStream, isSwitchingCamera, startWebcam, stopWebcam, currentFacingMode]);
|
96 |
|
97 |
-
|
98 |
-
useEffect(() => {
|
99 |
-
const onData = (base64: string) => {
|
100 |
-
if (client && connected && isAppMicActive) {
|
101 |
-
client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]);
|
102 |
-
}
|
103 |
-
};
|
104 |
-
if (connected && isAppMicActive && audioRecorder) {
|
105 |
-
audioRecorder.on("data", onData).start();
|
106 |
-
} else if (audioRecorder && audioRecorder.recording) {
|
107 |
-
audioRecorder.stop();
|
108 |
-
}
|
109 |
-
return () => {
|
110 |
-
if (audioRecorder) {
|
111 |
-
audioRecorder.off("data", onData);
|
112 |
-
if (audioRecorder.recording) audioRecorder.stop();
|
113 |
-
}
|
114 |
-
};
|
115 |
-
}, [connected, client, isAppMicActive, audioRecorder]);
|
116 |
-
|
117 |
useEffect(() => {
|
118 |
let timeoutId = -1;
|
119 |
function sendVideoFrame() {
|
@@ -138,13 +126,9 @@ const ControlTray: React.FC<ControlTrayProps> = ({
|
|
138 |
}
|
139 |
} catch (error) { console.error("❌ Error frame:", error); }
|
140 |
}
|
141 |
-
if (connected && activeLocalVideoStream)
|
142 |
-
timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4);
|
143 |
-
}
|
144 |
-
}
|
145 |
-
if (connected && activeLocalVideoStream && videoRef.current) {
|
146 |
-
timeoutId = window.setTimeout(sendVideoFrame, 200);
|
147 |
}
|
|
|
148 |
return () => clearTimeout(timeoutId);
|
149 |
}, [connected, activeLocalVideoStream, client, videoRef, renderCanvasRef]);
|
150 |
|
@@ -168,15 +152,9 @@ const ControlTray: React.FC<ControlTrayProps> = ({
|
|
168 |
const handleCamToggle = async () => {
|
169 |
if (isSwitchingCamera) return;
|
170 |
const newCamState = !isAppCamActive;
|
171 |
-
|
172 |
if (newCamState) {
|
173 |
-
if (!(await ensureConnectedAndReady())) {
|
174 |
-
|
175 |
-
return;
|
176 |
-
}
|
177 |
-
if (!isAppMicActive) {
|
178 |
-
onAppMicToggle(true);
|
179 |
-
}
|
180 |
onAppCamToggle(true);
|
181 |
} else {
|
182 |
onAppCamToggle(false);
|
@@ -186,13 +164,10 @@ const ControlTray: React.FC<ControlTrayProps> = ({
|
|
186 |
const handleSwitchCamera = async () => {
|
187 |
if (!isAppCamActive || !activeLocalVideoStream || isSwitchingCamera) return;
|
188 |
setIsSwitchingCamera(true);
|
189 |
-
|
190 |
const targetFacingMode = currentFacingMode === 'user' ? 'environment' : 'user';
|
191 |
-
|
192 |
activeLocalVideoStream.getTracks().forEach(track => track.stop());
|
193 |
setActiveLocalVideoStream(null);
|
194 |
onVideoStreamChange(null);
|
195 |
-
|
196 |
try {
|
197 |
const newStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: targetFacingMode } }, audio: false });
|
198 |
setActiveLocalVideoStream(newStream);
|
@@ -201,7 +176,6 @@ const ControlTray: React.FC<ControlTrayProps> = ({
|
|
201 |
} catch (error) {
|
202 |
console.error(`❌ Switch Cam err to ${targetFacingMode}:`, error);
|
203 |
try {
|
204 |
-
console.log(`Attempting to restore to ${currentFacingMode}`);
|
205 |
const restoredStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: {exact: currentFacingMode } }, audio: false });
|
206 |
setActiveLocalVideoStream(restoredStream);
|
207 |
onVideoStreamChange(restoredStream);
|
@@ -219,50 +193,15 @@ const ControlTray: React.FC<ControlTrayProps> = ({
|
|
219 |
return (
|
220 |
<footer id="footer-controls" className="footer-controls-html-like">
|
221 |
<canvas style={{ display: "none" }} ref={renderCanvasRef} />
|
222 |
-
|
223 |
-
|
224 |
-
id="mic-button"
|
225 |
-
className="control-button mic-button-color"
|
226 |
-
onClick={handleMicToggle}
|
227 |
-
>
|
228 |
-
{isAppMicActive ? pauseIcon : microphoneIcon}
|
229 |
</div>
|
230 |
-
|
231 |
-
{(isAppMicActive || isAppCamActive) && (
|
232 |
-
<div
|
233 |
-
id="small-logo-footer-container"
|
234 |
-
className={cn("small-logo-footer-html-like", {
|
235 |
-
'user-is-speaking-pulse': isAppMicActive,
|
236 |
-
})}
|
237 |
-
>
|
238 |
-
<Logo
|
239 |
-
isMini={true}
|
240 |
-
isActive={true}
|
241 |
-
isAi={false}
|
242 |
-
speakingVolume={volume}
|
243 |
-
/>
|
244 |
-
</div>
|
245 |
-
)}
|
246 |
-
|
247 |
<div id="cam-button-wrapper" className="control-button-wrapper cam-wrapper-html-like">
|
248 |
-
<div
|
249 |
-
id="cam-button"
|
250 |
-
className="control-button cam-button-color"
|
251 |
-
onClick={handleCamToggle}
|
252 |
-
>
|
253 |
{isAppCamActive ? stopCamIcon : cameraIcon}
|
254 |
</div>
|
255 |
-
<div
|
256 |
-
id="switch-camera-button-
|
257 |
-
className={cn("switch-camera-button-container", { visible: isAppCamActive && !isSwitchingCamera })}
|
258 |
-
>
|
259 |
-
<button
|
260 |
-
id="switch-camera-button"
|
261 |
-
aria-label="Switch Camera"
|
262 |
-
className="switch-camera-button-content group"
|
263 |
-
onClick={handleSwitchCamera}
|
264 |
-
disabled={!isAppCamActive || isSwitchingCamera}
|
265 |
-
>
|
266 |
<SvgSwitchCameraIcon/>
|
267 |
</button>
|
268 |
</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 { useLiveAPIContext } from "../../contexts/LiveAPIContext";
|
6 |
import { AudioRecorder } from "../../lib/audio-recorder";
|
7 |
+
import { PauseIconWithPulse, microphoneIcon, cameraIcon, stopCamIcon } from '../icons';
|
|
|
8 |
|
9 |
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>;
|
10 |
|
|
|
18 |
onAppCamToggle: (active: boolean) => void;
|
19 |
currentFacingMode: 'user' | 'environment';
|
20 |
onFacingModeChange: (mode: 'user' | 'environment') => void;
|
21 |
+
onUserSpeakingChange: (isSpeaking: boolean) => void;
|
22 |
};
|
23 |
|
24 |
+
const ControlTray: React.FC<ControlTrayProps> = ({ videoRef, onVideoStreamChange, supportsVideo, isAppMicActive, onAppMicToggle, isAppCamActive, onAppCamToggle, currentFacingMode, onFacingModeChange, onUserSpeakingChange }) => {
|
25 |
+
const { client, connected, connect } = useLiveAPIContext();
|
26 |
+
const audioRecorderRef = useRef<AudioRecorder | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
const [activeLocalVideoStream, setActiveLocalVideoStream] = useState<MediaStream | null>(null);
|
28 |
const [isSwitchingCamera, setIsSwitchingCamera] = useState(false);
|
29 |
const renderCanvasRef = React.useRef<HTMLCanvasElement>(null);
|
30 |
+
const [userVolume, setUserVolume] = useState(0);
|
31 |
|
32 |
+
useEffect(() => {
|
33 |
+
if (!audioRecorderRef.current) {
|
34 |
+
audioRecorderRef.current = new AudioRecorder();
|
35 |
+
}
|
36 |
+
const audioRecorder = audioRecorderRef.current;
|
37 |
+
|
38 |
+
const onData = (base64: string, volume: number) => {
|
39 |
+
if (client && connected && isAppMicActive) {
|
40 |
+
client.sendRealtimeInput([{ mimeType: "audio/pcm;rate=16000", data: base64 }]);
|
41 |
+
setUserVolume(volume);
|
42 |
+
onUserSpeakingChange(volume > 0.01);
|
43 |
+
}
|
44 |
+
};
|
45 |
+
const onStop = () => {
|
46 |
+
setUserVolume(0);
|
47 |
+
onUserSpeakingChange(false);
|
48 |
+
}
|
49 |
+
|
50 |
+
if (connected && isAppMicActive) {
|
51 |
+
audioRecorder.on("data", onData).on("stop", onStop).start();
|
52 |
+
} else {
|
53 |
+
if (audioRecorder.recording) audioRecorder.stop();
|
54 |
+
}
|
55 |
+
return () => {
|
56 |
+
if (audioRecorder) {
|
57 |
+
audioRecorder.off("data", onData).off("stop", onStop);
|
58 |
+
if (audioRecorder.recording) audioRecorder.stop();
|
59 |
+
}
|
60 |
+
};
|
61 |
+
}, [connected, client, isAppMicActive, onUserSpeakingChange]);
|
62 |
+
|
63 |
useEffect(() => {
|
64 |
if (videoRef.current) {
|
65 |
if (videoRef.current.srcObject !== activeLocalVideoStream) {
|
66 |
videoRef.current.srcObject = activeLocalVideoStream;
|
67 |
+
if (activeLocalVideoStream) videoRef.current.play().catch(e => console.warn("Video play failed:", e));
|
|
|
|
|
68 |
}
|
69 |
}
|
70 |
}, [activeLocalVideoStream, videoRef]);
|
71 |
|
72 |
const stopWebcam = useCallback(() => {
|
73 |
+
if (activeLocalVideoStream) activeLocalVideoStream.getTracks().forEach(track => track.stop());
|
|
|
|
|
74 |
setActiveLocalVideoStream(null);
|
75 |
onVideoStreamChange(null);
|
76 |
}, [activeLocalVideoStream, onVideoStreamChange]);
|
|
|
78 |
const startWebcam = useCallback(async (facingModeToTry: 'user' | 'environment') => {
|
79 |
if (isSwitchingCamera) return;
|
80 |
setIsSwitchingCamera(true);
|
81 |
+
if (activeLocalVideoStream) activeLocalVideoStream.getTracks().forEach(track => track.stop());
|
|
|
|
|
|
|
|
|
82 |
try {
|
83 |
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingModeToTry }, audio: false });
|
84 |
setActiveLocalVideoStream(mediaStream);
|
|
|
92 |
} finally {
|
93 |
setIsSwitchingCamera(false);
|
94 |
}
|
95 |
+
}, [isSwitchingCamera, activeLocalVideoStream, onVideoStreamChange, onFacingModeChange, onAppCamToggle]);
|
|
|
|
|
|
|
|
|
|
|
|
|
96 |
|
97 |
useEffect(() => {
|
98 |
if (isAppCamActive && !activeLocalVideoStream && !isSwitchingCamera) {
|
|
|
102 |
}
|
103 |
}, [isAppCamActive, activeLocalVideoStream, isSwitchingCamera, startWebcam, stopWebcam, currentFacingMode]);
|
104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
105 |
useEffect(() => {
|
106 |
let timeoutId = -1;
|
107 |
function sendVideoFrame() {
|
|
|
126 |
}
|
127 |
} catch (error) { console.error("❌ Error frame:", error); }
|
128 |
}
|
129 |
+
if (connected && activeLocalVideoStream) timeoutId = window.setTimeout(sendVideoFrame, 1000 / 4);
|
|
|
|
|
|
|
|
|
|
|
130 |
}
|
131 |
+
if (connected && activeLocalVideoStream && videoRef.current) timeoutId = window.setTimeout(sendVideoFrame, 200);
|
132 |
return () => clearTimeout(timeoutId);
|
133 |
}, [connected, activeLocalVideoStream, client, videoRef, renderCanvasRef]);
|
134 |
|
|
|
152 |
const handleCamToggle = async () => {
|
153 |
if (isSwitchingCamera) return;
|
154 |
const newCamState = !isAppCamActive;
|
|
|
155 |
if (newCamState) {
|
156 |
+
if (!(await ensureConnectedAndReady())) { onAppCamToggle(false); return; }
|
157 |
+
if (!isAppMicActive) onAppMicToggle(true);
|
|
|
|
|
|
|
|
|
|
|
158 |
onAppCamToggle(true);
|
159 |
} else {
|
160 |
onAppCamToggle(false);
|
|
|
164 |
const handleSwitchCamera = async () => {
|
165 |
if (!isAppCamActive || !activeLocalVideoStream || isSwitchingCamera) return;
|
166 |
setIsSwitchingCamera(true);
|
|
|
167 |
const targetFacingMode = currentFacingMode === 'user' ? 'environment' : 'user';
|
|
|
168 |
activeLocalVideoStream.getTracks().forEach(track => track.stop());
|
169 |
setActiveLocalVideoStream(null);
|
170 |
onVideoStreamChange(null);
|
|
|
171 |
try {
|
172 |
const newStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { exact: targetFacingMode } }, audio: false });
|
173 |
setActiveLocalVideoStream(newStream);
|
|
|
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 |
onVideoStreamChange(restoredStream);
|
|
|
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 ? <PauseIconWithPulse userVolume={userVolume} /> : microphoneIcon}
|
|
|
|
|
|
|
|
|
|
|
198 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
199 |
<div id="cam-button-wrapper" className="control-button-wrapper cam-wrapper-html-like">
|
200 |
+
<div id="cam-button" className="control-button cam-button-color" onClick={handleCamToggle}>
|
|
|
|
|
|
|
|
|
201 |
{isAppCamActive ? stopCamIcon : cameraIcon}
|
202 |
</div>
|
203 |
+
<div id="switch-camera-button-container" className={cn("switch-camera-button-container", { visible: isAppCamActive && !isSwitchingCamera })}>
|
204 |
+
<button id="switch-camera-button" aria-label="Switch Camera" className="switch-camera-button-content group" onClick={handleSwitchCamera} disabled={!isAppCamActive || isSwitchingCamera}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
205 |
<SvgSwitchCameraIcon/>
|
206 |
</button>
|
207 |
</div>
|