diff --git a/frontend/src/components/VideoOutput.tsx b/frontend/src/components/VideoOutput.tsx index 312477ca8..fa9bf80a2 100644 --- a/frontend/src/components/VideoOutput.tsx +++ b/frontend/src/components/VideoOutput.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, useCallback } from "react"; +import { Volume2, VolumeX } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Spinner } from "./ui/spinner"; import { PlayOverlay } from "./ui/play-overlay"; @@ -49,15 +50,47 @@ export function VideoOutput({ const [isFadingOut, setIsFadingOut] = useState(false); const overlayTimeoutRef = useRef(null); + // Audio state: start muted to comply with browser autoplay policy. + // User can click the speaker icon to unmute once the stream is playing. + const [isMuted, setIsMuted] = useState(true); + const [hasAudioTrack, setHasAudioTrack] = useState(false); + // Use external ref if provided, otherwise use internal const containerRef = videoContainerRef || internalContainerRef; useEffect(() => { if (videoRef.current && remoteStream) { videoRef.current.srcObject = remoteStream; + + // Check if the stream contains an audio track + const audioTracks = remoteStream.getAudioTracks(); + setHasAudioTrack(audioTracks.length > 0); + + // Listen for tracks being added later (audio may arrive after video) + const handleTrackAdded = () => { + const tracks = remoteStream.getAudioTracks(); + setHasAudioTrack(tracks.length > 0); + }; + remoteStream.addEventListener("addtrack", handleTrackAdded); + + return () => { + remoteStream.removeEventListener("addtrack", handleTrackAdded); + }; } }, [remoteStream]); + // Sync muted state to the video element + useEffect(() => { + if (videoRef.current) { + videoRef.current.muted = isMuted; + } + }, [isMuted]); + + const toggleMute = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); // Don't trigger play/pause or pointer lock + setIsMuted(prev => !prev); + }, []); + // Listen for video playing event to notify parent useEffect(() => { const video = videoRef.current; @@ -174,9 +207,23 @@ export function VideoOutput({ : "max-w-full max-h-full object-contain" } autoPlay - muted + muted={isMuted} playsInline /> + {/* Audio mute/unmute toggle - only shown when stream has audio */} + {hasAudioTrack && ( + + )} {/* Play/Pause Overlay */} {showOverlay && (
diff --git a/frontend/src/hooks/useUnifiedWebRTC.ts b/frontend/src/hooks/useUnifiedWebRTC.ts index 190d1d41f..27cbf9788 100644 --- a/frontend/src/hooks/useUnifiedWebRTC.ts +++ b/frontend/src/hooks/useUnifiedWebRTC.ts @@ -208,6 +208,11 @@ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { transceiver = pc.addTransceiver("video"); } + // Add a receive-only audio transceiver so the SDP offer includes an + // audio m-line. The backend will attach its audio track to this + // transceiver after processing the offer. + pc.addTransceiver("audio", { direction: "recvonly" }); + // Force VP8-only for aiortc compatibility if (transceiver) { const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs || []; @@ -221,11 +226,18 @@ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { } // Event handlers + // Collect all incoming tracks (video + audio) into a single MediaStream. + // The backend sends video and audio as separate tracks; we merge them + // into one MediaStream for the