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:
parent
870921a482
commit
694a0aaaa2
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// 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">
|
||||||
<i className="fa-solid fa-spinner fa-spin" />
|
{cameraStarting ? (
|
||||||
<span>Starting camera...</span>
|
<>
|
||||||
|
<i className="fa-solid fa-spinner fa-spin" />
|
||||||
|
<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">
|
||||||
<button
|
{hasDualCameras && (
|
||||||
type="button"
|
<button
|
||||||
className="sw-broadcast-control-btn"
|
type="button"
|
||||||
onClick={switchCamera}
|
className="sw-broadcast-control-btn"
|
||||||
disabled={isSwitchingCamera}
|
onClick={switchCamera}
|
||||||
title={facingMode === "user" ? "Switch to back camera" : "Switch to front camera"}
|
disabled={isSwapping}
|
||||||
>
|
title={isSwapped ? "Switch to front camera" : "Switch to back camera"}
|
||||||
{isSwitchingCamera ? (
|
>
|
||||||
<i className="fa-solid fa-spinner fa-spin" />
|
{isSwapping ? (
|
||||||
) : (
|
<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 ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue