import { useState, useRef, useEffect, useCallback } from "react"; import { useLiveKit } from "./useLiveKit"; import { useDualCamera } from "./useDualCamera"; import { useAuth } from "../../auth/AuthContext"; import { fetchPostComments, createPostComment } from "../../api/client"; import { CREATE_CATEGORY_MAP, FILTER_MAIN_CATEGORIES } from "../Map/mapConfig"; 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 [showConfirm, setShowConfirm] = useState(false); const [livePostId, setLivePostId] = useState(null); const liveCategoryOptions = FILTER_MAIN_CATEGORIES.filter((c) => c !== "All"); const [liveCategory, setLiveCategory] = useState("Events"); const [liveSubCategory, setLiveSubCategory] = useState("Live"); // Timer state const [liveStartTime, setLiveStartTime] = useState(null); const [elapsedTime, setElapsedTime] = useState(0); // Comments state (stored in the post so they persist after live) 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); // Camera hook - handles device enumeration and swapping const { mainStream, isSwapped, error: cameraError, isStarting: cameraStarting, isSwapping, hasDualCameras, startCameras, swapCameras, stopCameras, } = useDualCamera(); const { isLive, isConnecting, error: liveKitError, viewerCount, startBroadcast, stopBroadcast, replaceVideoTrack, } = useLiveKit({ roomName, userID, username, token: authToken, }); const error = cameraError || liveKitError; const subCategoryOptions = (() => { const options = Array.isArray(CREATE_CATEGORY_MAP[liveCategory]) ? CREATE_CATEGORY_MAP[liveCategory] : []; const withLive = ["Live", ...options]; const deduped = []; const seen = new Set(); for (const item of withLive) { const label = (item || "").toString().trim(); if (!label || seen.has(label)) continue; seen.add(label); deduped.push(label); } return deduped; })(); useEffect(() => { if (!subCategoryOptions.length) return; if (!subCategoryOptions.includes(liveSubCategory)) { setLiveSubCategory(subCategoryOptions[0]); } }, [liveCategory]); // Start camera on mount useEffect(() => { startCameras(); return () => { stopCameras(); }; }, []); // Update video element when stream changes useEffect(() => { const el = videoRef.current; if (!el || !mainStream) return; el.srcObject = mainStream; el.muted = true; const tryPlay = () => { const p = el.play(); if (p && typeof p.catch === "function") { p.catch(() => {}); } }; el.onloadedmetadata = tryPlay; tryPlay(); }, [mainStream]); // 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 (isSwapping || !hasDualCameras) return; await swapCameras(); // 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]); // Update LiveKit track when mainStream changes during live useEffect(() => { if (isLive && mainStream) { const newVideoTrack = mainStream.getVideoTracks()[0]; if (newVideoTrack) { replaceVideoTrack(newVideoTrack); } } }, [mainStream, isLive, replaceVideoTrack]); const refreshComments = useCallback(async () => { if (!livePostId) return; const items = await fetchPostComments(livePostId, 50); if (Array.isArray(items)) { setComments(items); } }, [livePostId]); useEffect(() => { if (!isLive || !livePostId) return; let active = true; const poll = async () => { if (!active) return; await refreshComments(); }; poll(); const timer = setInterval(poll, 4000); return () => { active = false; clearInterval(timer); }; }, [isLive, livePostId, refreshComments]); // Add a comment (persist to post immediately) const handleAddComment = useCallback(async () => { const body = commentInput.trim(); if (!body || !livePostId) return; setCommentInput(""); const res = await createPostComment(livePostId, body); if (res?.ok) { await refreshComments(); } }, [commentInput, livePostId, refreshComments]); 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 (!mainStream) { alert("Camera not ready"); return; } // Get video track from stream const videoTrack = mainStream.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${liveSubCategory ? ` ยท ${liveSubCategory}` : ""}`, category: liveCategory === "Events" ? "EVENT" : liveCategory.toUpperCase(), sub_category: liveSubCategory || "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, mainStream, startBroadcast, roomName, liveCoords, onStreamCreated, authToken, username, liveCategory, liveSubCategory, ]); const handleEndStream = useCallback(async (save) => { await stopBroadcast(); stopCameras(); // Stop egress recording and get video key let videoKey = ""; let thumbnailKey = ""; 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 || ""; thumbnailKey = egressData.thumbnail_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, thumbnail_key: thumbnailKey, comments: [], }), }); 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, stopCameras, onClose, livePostId, authToken, roomName, comments]); const handleBackdropClick = (e) => { if (e.target === e.currentTarget && !isLive) { stopCameras(); onClose?.(); } }; return (
{/* Header */}
{isLive ? "LIVE" : "Go Live"} {isLive && ( <>
{viewerCount} watching
{formatTime(elapsedTime)}
)}
{/* Content */}
{/* Video preview */}
{mainStream ? (
{/* 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 ? (
{livePostId ? "No comments yet" : "Connecting chat..."}
) : ( comments.map((c, idx) => (
{c.username || c.author || "User"} {c.body || c.text || ""}
)) )}
setCommentInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleAddComment()} maxLength={200} disabled={!livePostId} />
)} {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

)}
)}
); }