import { useState, useRef, useEffect, useCallback } from "react"; import { useLiveKit } from "./useLiveKit"; import { useAuth } from "../../auth/AuthContext"; import "./Live.css"; const API_BASE = (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, ""); const LIVEKIT_API = "https://live.us1.sociowire.com"; /** * LiveKit-based broadcast component * Professional video streaming that works everywhere (5G, 4G, WiFi, firewalls) */ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { const { username, token: authToken } = useAuth(); const userID = username || `anon_${Date.now()}`; 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); const [elapsedTime, setElapsedTime] = useState(0); // Comments state const [comments, setComments] = useState([]); const [commentInput, setCommentInput] = useState(""); const commentsEndRef = useRef(null); const [liveCoords] = useState(() => { if (coords && typeof coords === "object") { if (Array.isArray(coords) && coords.length >= 2) { return { lon: coords[0], lat: coords[1] }; } const lat = coords.lat ?? coords.latitude; const lon = coords.lon ?? coords.lng ?? coords.longitude; if (Number.isFinite(lat) && Number.isFinite(lon)) { return { lat, lon }; } } return { lat: null, lon: null }; }); const videoRef = useRef(null); const { isLive, isConnecting, error, viewerCount, startBroadcast, stopBroadcast, replaceVideoTrack, } = useLiveKit({ roomName, userID, username, token: authToken, }); // 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(); return () => { if (localStream) { localStream.getTracks().forEach((t) => t.stop()); } }; }, []); // Update video element when stream changes useEffect(() => { if (videoRef.current && localStream) { videoRef.current.srcObject = localStream; } }, [localStream]); // Timer effect - update every second when live useEffect(() => { if (!isLive || !liveStartTime) return; const interval = setInterval(() => { setElapsedTime(Math.floor((Date.now() - liveStartTime) / 1000)); }, 1000); return () => clearInterval(interval); }, [isLive, liveStartTime]); // Auto-scroll comments useEffect(() => { commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [comments]); // Format elapsed time as MM:SS const formatTime = (seconds) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; }; // Switch between front and back camera const switchCamera = useCallback(async () => { if (isSwitchingCamera) return; setIsSwitchingCamera(true); try { const newFacingMode = facingMode === "user" ? "environment" : "user"; // Get new stream with switched camera const newStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: newFacingMode, width: 1280, height: 720 }, audio: true, }); // Stop old video tracks only (keep audio reference for LiveKit) if (localStream) { localStream.getVideoTracks().forEach((t) => t.stop()); } // 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]); // Add a comment const handleAddComment = useCallback(() => { if (!commentInput.trim()) return; const newComment = { id: Date.now(), author: username || "Host", text: commentInput.trim(), timestamp: Date.now(), }; setComments((prev) => [...prev, newComment]); setCommentInput(""); }, [commentInput, username]); const handleGoLive = useCallback(async () => { if (!authToken) { alert("Please log in to start a live stream."); return; } if (!title.trim()) { alert("Please enter a title"); return; } if (!localStream) { alert("Camera not ready"); return; } // Get video track from stream const videoTrack = localStream.getVideoTracks()[0]; await startBroadcast(videoTrack, true); // Start timer setLiveStartTime(Date.now()); setElapsedTime(0); if (onStreamCreated) { onStreamCreated({ roomName, title: title.trim(), coords: liveCoords, }); } try { const res = await fetch(`${API_BASE}/api/post`, { method: "POST", headers: { "Content-Type": "application/json", ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), }, body: JSON.stringify({ title: title.trim(), snippet: "Live now", category: "EVENT", sub_category: "Live", lat: liveCoords?.lat, lon: liveCoords?.lon, author: username, source: "sociowire", visibility: "public", content_type: "live", embed_url: `livekit://${roomName}`, }), }); if (res.ok) { const post = await res.json(); setLivePostId(post?.id || null); window.dispatchEvent(new CustomEvent("live-post-created", { detail: post })); } else { const errText = await res.text(); console.error("[LiveKit] Failed to create live post:", res.status, errText); alert("Live started, but we couldn't create the live post."); } } catch (err) { 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]); const handleEndStream = useCallback(async (save) => { await stopBroadcast(); if (localStream) { localStream.getTracks().forEach((t) => t.stop()); } // Stop egress recording and get video key let videoKey = ""; try { const egressRes = await fetch(`${LIVEKIT_API}/api/livekit/egress/stop`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ room: roomName, save: !!save }), }); if (egressRes.ok) { const egressData = await egressRes.json(); videoKey = egressData.key || ""; console.log("[LiveKit] Egress stopped, video key:", videoKey); } } catch (err) { console.warn("[LiveKit] Failed to stop egress:", err); } if (livePostId) { try { // Include comments in finalize request const res = await fetch(`${API_BASE}/api/live/finalize`, { method: "POST", headers: { "Content-Type": "application/json", ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), }, body: JSON.stringify({ post_id: livePostId, save: !!save, video_key: videoKey, comments: save ? comments.map((c) => ({ author: c.author, text: c.text, timestamp: c.timestamp, })) : [], }), }); if (res.ok && save) { const post = await res.json(); window.dispatchEvent(new CustomEvent("live-post-updated", { detail: post })); } if (res.ok && !save) { window.dispatchEvent(new CustomEvent("live-post-deleted", { detail: { post_id: livePostId } })); } if (!res.ok) { const errText = await res.text(); console.error("[LiveKit] Finalize failed:", res.status, errText); alert("We couldn't save the live post. Please try again."); } } catch (err) { console.error("[LiveKit] Failed to finalize live post:", err); alert("We couldn't save the live post. Please try again."); } } onClose?.(); }, [stopBroadcast, localStream, onClose, livePostId, authToken, roomName, comments]); const handleBackdropClick = (e) => { if (e.target === e.currentTarget && !isLive) { if (localStream) { localStream.getTracks().forEach((t) => t.stop()); } onClose?.(); } }; return (
{/* Header */}
{isLive ? "LIVE" : "Go Live"} {isLive && ( <>
{viewerCount} watching
{formatTime(elapsedTime)}
)}
{/* Content */}
{/* Video preview */}
{localStream ? (
{/* Side panel */}
{!isLive && (
setTitle(e.target.value)} maxLength={100} />
Using LiveKit - works on 5G/4G/WiFi
)} {isLive && ( <>

{title}

{viewerCount} viewers {formatTime(elapsedTime)}
{/* Comments section */}
Comments ({comments.length})
{comments.length === 0 ? (
No comments yet
) : ( comments.map((c) => (
{c.author} {c.text}
)) )}
setCommentInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleAddComment()} maxLength={200} />
)} {error && (
{error}
)}
{isLive ? ( ) : ( )}
{/* Confirm dialog */} {showConfirm && (

End Stream?

Your live stream will end for all viewers.

{comments.length > 0 && (

{comments.length} comments will be saved

)}
)}
); }