Spaces:
Running
Running
/** | |
* Copyright 2024 Google LLC | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
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 { audioContext } from "../../lib/utils"; | |
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; | |
}; | |
/** | |
* button used for triggering webcam or screen-capture | |
*/ | |
const MediaStreamButton = memo( | |
({ isStreaming, onIcon, offIcon, start, stop }: MediaStreamButtonProps) => | |
isStreaming ? ( | |
<button className="action-button" onClick={stop}> | |
<span className="material-symbols-outlined">{onIcon}</span> | |
</button> | |
) : ( | |
<button className="action-button" onClick={start}> | |
<span className="material-symbols-outlined">{offIcon}</span> | |
</button> | |
), | |
); | |
function ControlTray({ | |
videoRef, | |
children, | |
onVideoStreamChange = () => {}, | |
supportsVideo, | |
}: ControlTrayProps) { | |
const videoStreams = [useWebcam(), useScreenCapture()]; | |
const [activeVideoStream, setActiveVideoStream] = | |
useState<MediaStream | null>(null); | |
const [webcam, screenCapture] = videoStreams; | |
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(); | |
// Add iOS detection | |
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); | |
useEffect(() => { | |
if (!connected && connectButtonRef.current) { | |
connectButtonRef.current.focus(); | |
} | |
}, [connected]); | |
// Add iOS volume simulation effect | |
useEffect(() => { | |
if (isIOSDevice && connected && !muted) { | |
const interval = setInterval(() => { | |
// Create a smooth pulsing effect | |
const pulse = (Math.sin(Date.now() / 500) + 1) / 2; // Values between 0 and 1 | |
setSimulatedVolume(0.02 + pulse * 0.03); // Small range for subtle effect | |
}, 50); | |
return () => clearInterval(interval); | |
} | |
}, [connected, muted, isIOSDevice]); | |
useEffect(() => { | |
document.documentElement.style.setProperty( | |
"--volume", | |
`${Math.max(5, Math.min((isIOSDevice ? simulatedVolume : inVolume) * 200, 8))}px`, | |
); | |
}, [inVolume, simulatedVolume, isIOSDevice]); | |
useEffect(() => { | |
const onData = (base64: string) => { | |
client.sendRealtimeInput([ | |
{ | |
mimeType: "audio/pcm;rate=16000", | |
data: base64, | |
}, | |
]); | |
}; | |
if (connected && !muted && audioRecorder) { | |
audioRecorder.on("data", onData).on("volume", setInVolume).start(); | |
} else { | |
audioRecorder.stop(); | |
} | |
return () => { | |
audioRecorder.off("data", onData).off("volume", setInVolume); | |
}; | |
}, [connected, client, muted, audioRecorder]); | |
useEffect(() => { | |
if (videoRef.current) { | |
videoRef.current.srcObject = activeVideoStream; | |
} | |
let timeoutId = -1; | |
function sendVideoFrame() { | |
const video = videoRef.current; | |
const canvas = renderCanvasRef.current; | |
if (!video || !canvas) { | |
return; | |
} | |
const ctx = canvas.getContext("2d")!; | |
canvas.width = video.videoWidth * 0.25; | |
canvas.height = video.videoHeight * 0.25; | |
if (canvas.width + canvas.height > 0) { | |
ctx.drawImage(videoRef.current, 0, 0, canvas.width, canvas.height); | |
const base64 = canvas.toDataURL("image/jpeg", 1.0); | |
const data = base64.slice(base64.indexOf(",") + 1, Infinity); | |
client.sendRealtimeInput([{ mimeType: "image/jpeg", data }]); | |
} | |
if (connected) { | |
timeoutId = window.setTimeout(sendVideoFrame, 1000 / 0.5); | |
} | |
} | |
if (connected && activeVideoStream !== null) { | |
requestAnimationFrame(sendVideoFrame); | |
} | |
return () => { | |
clearTimeout(timeoutId); | |
}; | |
}, [connected, activeVideoStream, client, videoRef]); | |
//handler for swapping from one video-stream to the next | |
const changeStreams = (next?: UseMediaStreamResult) => async () => { | |
if (next) { | |
const mediaStream = await next.start(); | |
setActiveVideoStream(mediaStream); | |
onVideoStreamChange(mediaStream); | |
} else { | |
setActiveVideoStream(null); | |
onVideoStreamChange(null); | |
} | |
videoStreams.filter((msr) => msr !== next).forEach((msr) => msr.stop()); | |
}; | |
return ( | |
<section className="control-tray"> | |
<canvas style={{ display: "none" }} ref={renderCanvasRef} /> | |
<nav className={cn("actions-nav", { disabled: !connected })}> | |
<button | |
className={cn("action-button mic-button")} | |
onClick={() => setMuted(!muted)} | |
> | |
{!muted ? ( | |
<span className="material-symbols-outlined filled">mic</span> | |
) : ( | |
<span className="material-symbols-outlined filled">mic_off</span> | |
)} | |
</button> | |
<div className="action-button no-action outlined"> | |
<AudioPulse volume={volume} active={connected} hover={false} /> | |
</div> | |
{supportsVideo && ( | |
<> | |
<MediaStreamButton | |
isStreaming={screenCapture.isStreaming} | |
start={changeStreams(screenCapture)} | |
stop={changeStreams()} | |
onIcon="cancel_presentation" | |
offIcon="present_to_all" | |
/> | |
<MediaStreamButton | |
isStreaming={webcam.isStreaming} | |
start={changeStreams(webcam)} | |
stop={changeStreams()} | |
onIcon="videocam_off" | |
offIcon="videocam" | |
/> | |
</> | |
)} | |
{children} | |
</nav> | |
<div className={cn("connection-container", { connected })}> | |
<div className="connection-button-container"> | |
<button | |
ref={connectButtonRef} | |
className={cn("action-button connect-toggle", { connected })} | |
onClick={async () => { | |
console.log('π Connection button clicked'); | |
try { | |
if (connected) { | |
console.log('π΄ Disconnecting...'); | |
await disconnect(); | |
console.log('β Disconnected successfully'); | |
} else { | |
console.log('π Starting connection...'); | |
console.log('π± Device info:', { isIOSDevice, isSafari }); | |
// We already have mic permissions from the modal, just connect | |
console.log('π Calling connect()...'); | |
await connect(); | |
console.log('β Connected successfully'); | |
} | |
} catch (err) { | |
console.error('β Failed to toggle connection:', err); | |
// Here you could add UI feedback about the error | |
} | |
}} | |
> | |
<span className="material-symbols-outlined filled"> | |
{connected ? "pause" : "play_arrow"} | |
</span> | |
</button> | |
</div> | |
<span className="text-indicator">Streaming</span> | |
</div> | |
</section> | |
); | |
} | |
export default memo(ControlTray); | |