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>
|