Fix camera switch using useDualCamera hook

- Use useDualCamera for device enumeration and reliable switching
- Auto-update LiveKit video track when camera swaps during live
- Only show switch button if device has 2 cameras
- Better camera unavailable state handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SocioWire 2026-01-07 20:55:53 +00:00
parent 870921a482
commit 694a0aaaa2
1 changed files with 69 additions and 90 deletions

View File

@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useLiveKit } from "./useLiveKit"; import { useLiveKit } from "./useLiveKit";
import { useDualCamera } from "./useDualCamera";
import { useAuth } from "../../auth/AuthContext"; import { useAuth } from "../../auth/AuthContext";
import "./Live.css"; import "./Live.css";
@ -16,11 +17,8 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
const [roomName] = useState(() => `live_${userID}_${Date.now()}`); const [roomName] = useState(() => `live_${userID}_${Date.now()}`);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [localStream, setLocalStream] = useState(null);
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const [livePostId, setLivePostId] = useState(null); const [livePostId, setLivePostId] = useState(null);
const [facingMode, setFacingMode] = useState("user");
const [isSwitchingCamera, setIsSwitchingCamera] = useState(false);
// Timer state // Timer state
const [liveStartTime, setLiveStartTime] = useState(null); const [liveStartTime, setLiveStartTime] = useState(null);
@ -47,10 +45,23 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
const videoRef = useRef(null); const videoRef = useRef(null);
// Camera hook - handles device enumeration and swapping
const {
mainStream,
isSwapped,
error: cameraError,
isStarting: cameraStarting,
isSwapping,
hasDualCameras,
startCameras,
swapCameras,
stopCameras,
} = useDualCamera();
const { const {
isLive, isLive,
isConnecting, isConnecting,
error, error: liveKitError,
viewerCount, viewerCount,
startBroadcast, startBroadcast,
stopBroadcast, stopBroadcast,
@ -62,37 +73,22 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
token: authToken, token: authToken,
}); });
const error = cameraError || liveKitError;
// Start camera on mount // Start camera on mount
useEffect(() => { useEffect(() => {
const startCamera = async () => { startCameras();
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user", width: 1280, height: 720 },
audio: true,
});
setLocalStream(stream);
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (err) {
console.error("[LiveKit] Camera error:", err);
}
};
startCamera();
return () => { return () => {
if (localStream) { stopCameras();
localStream.getTracks().forEach((t) => t.stop());
}
}; };
}, []); }, []);
// Update video element when stream changes // Update video element when stream changes
useEffect(() => { useEffect(() => {
if (videoRef.current && localStream) { if (videoRef.current && mainStream) {
videoRef.current.srcObject = localStream; videoRef.current.srcObject = mainStream;
} }
}, [localStream]); }, [mainStream]);
// Timer effect - update every second when live // Timer effect - update every second when live
useEffect(() => { useEffect(() => {
@ -119,45 +115,23 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
// Switch between front and back camera // Switch between front and back camera
const switchCamera = useCallback(async () => { const switchCamera = useCallback(async () => {
if (isSwitchingCamera) return; if (isSwapping || !hasDualCameras) return;
setIsSwitchingCamera(true);
try { await swapCameras();
const newFacingMode = facingMode === "user" ? "environment" : "user";
// Get new stream with switched camera // If live, replace the video track in LiveKit after swap
const newStream = await navigator.mediaDevices.getUserMedia({ // Note: mainStream will be updated by useDualCamera, we need to wait for it
video: { facingMode: newFacingMode, width: 1280, height: 720 }, }, [isSwapping, hasDualCameras, swapCameras]);
audio: true,
});
// Stop old video tracks only (keep audio reference for LiveKit) // Update LiveKit track when mainStream changes during live
if (localStream) { useEffect(() => {
localStream.getVideoTracks().forEach((t) => t.stop()); if (isLive && mainStream) {
const newVideoTrack = mainStream.getVideoTracks()[0];
if (newVideoTrack) {
replaceVideoTrack(newVideoTrack);
} }
// If live, replace the video track in LiveKit
if (isLive) {
const newVideoTrack = newStream.getVideoTracks()[0];
await replaceVideoTrack(newVideoTrack);
} }
}, [mainStream, isLive, replaceVideoTrack]);
// Update state
setLocalStream(newStream);
setFacingMode(newFacingMode);
// Update video element
if (videoRef.current) {
videoRef.current.srcObject = newStream;
}
console.log("[LiveKit] Switched to", newFacingMode, "camera");
} catch (err) {
console.error("[LiveKit] Failed to switch camera:", err);
} finally {
setIsSwitchingCamera(false);
}
}, [facingMode, localStream, isSwitchingCamera, isLive, replaceVideoTrack]);
// Add a comment // Add a comment
const handleAddComment = useCallback(() => { const handleAddComment = useCallback(() => {
@ -183,13 +157,13 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
alert("Please enter a title"); alert("Please enter a title");
return; return;
} }
if (!localStream) { if (!mainStream) {
alert("Camera not ready"); alert("Camera not ready");
return; return;
} }
// Get video track from stream // Get video track from stream
const videoTrack = localStream.getVideoTracks()[0]; const videoTrack = mainStream.getVideoTracks()[0];
await startBroadcast(videoTrack, true); await startBroadcast(videoTrack, true);
// Start timer // Start timer
@ -238,13 +212,11 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
console.error("[LiveKit] Failed to create live post:", err); console.error("[LiveKit] Failed to create live post:", err);
alert("Live started, but we couldn't create the live post."); alert("Live started, but we couldn't create the live post.");
} }
}, [title, localStream, startBroadcast, roomName, liveCoords, onStreamCreated, authToken, username]); }, [title, mainStream, startBroadcast, roomName, liveCoords, onStreamCreated, authToken, username]);
const handleEndStream = useCallback(async (save) => { const handleEndStream = useCallback(async (save) => {
await stopBroadcast(); await stopBroadcast();
if (localStream) { stopCameras();
localStream.getTracks().forEach((t) => t.stop());
}
// Stop egress recording and get video key // Stop egress recording and get video key
let videoKey = ""; let videoKey = "";
@ -301,13 +273,11 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
} }
} }
onClose?.(); onClose?.();
}, [stopBroadcast, localStream, onClose, livePostId, authToken, roomName, comments]); }, [stopBroadcast, stopCameras, onClose, livePostId, authToken, roomName, comments]);
const handleBackdropClick = (e) => { const handleBackdropClick = (e) => {
if (e.target === e.currentTarget && !isLive) { if (e.target === e.currentTarget && !isLive) {
if (localStream) { stopCameras();
localStream.getTracks().forEach((t) => t.stop());
}
onClose?.(); onClose?.();
} }
}; };
@ -340,9 +310,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
if (isLive) { if (isLive) {
setShowConfirm(true); setShowConfirm(true);
} else { } else {
if (localStream) { stopCameras();
localStream.getTracks().forEach((t) => t.stop());
}
onClose?.(); onClose?.();
} }
}} }}
@ -356,7 +324,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
{/* Video preview */} {/* Video preview */}
<div className="sw-broadcast-video-area"> <div className="sw-broadcast-video-area">
<div className="sw-broadcast-main-video"> <div className="sw-broadcast-main-video">
{localStream ? ( {mainStream ? (
<video <video
ref={videoRef} ref={videoRef}
autoPlay autoPlay
@ -366,8 +334,17 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
/> />
) : ( ) : (
<div className="sw-broadcast-video-placeholder"> <div className="sw-broadcast-video-placeholder">
{cameraStarting ? (
<>
<i className="fa-solid fa-spinner fa-spin" /> <i className="fa-solid fa-spinner fa-spin" />
<span>Starting camera...</span> <span>Starting camera...</span>
</>
) : (
<>
<i className="fa-solid fa-video-slash" />
<span>Camera unavailable</span>
</>
)}
</div> </div>
)} )}
@ -380,19 +357,21 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
{/* Camera controls overlay */} {/* Camera controls overlay */}
<div className="sw-broadcast-controls-overlay"> <div className="sw-broadcast-controls-overlay">
{hasDualCameras && (
<button <button
type="button" type="button"
className="sw-broadcast-control-btn" className="sw-broadcast-control-btn"
onClick={switchCamera} onClick={switchCamera}
disabled={isSwitchingCamera} disabled={isSwapping}
title={facingMode === "user" ? "Switch to back camera" : "Switch to front camera"} title={isSwapped ? "Switch to front camera" : "Switch to back camera"}
> >
{isSwitchingCamera ? ( {isSwapping ? (
<i className="fa-solid fa-spinner fa-spin" /> <i className="fa-solid fa-spinner fa-spin" />
) : ( ) : (
<i className="fa-solid fa-camera-rotate" /> <i className="fa-solid fa-camera-rotate" />
)} )}
</button> </button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -496,7 +475,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
type="button" type="button"
className="sw-broadcast-btn primary" className="sw-broadcast-btn primary"
onClick={handleGoLive} onClick={handleGoLive}
disabled={isConnecting || !localStream} disabled={isConnecting || !mainStream}
> >
{isConnecting ? ( {isConnecting ? (
<> <>