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