From bacf7d9df3c660a2f7b34543cf911ced78893708 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 20 Jan 2026 07:20:58 +0000 Subject: [PATCH] Redesign LiveKitBroadcast with fullscreen experience (v0.0.92) - Fullscreen video preview with gradient overlays - Pre-live: Collapsible settings panel for title/category - Live: Floating comments section with toggle - Modern pill-style badges for LIVE/viewers/timer - Camera switch and settings buttons on right side - End stream confirmation with Save/Discard/Continue options - Websocket events for live-post-updated/deleted already handled Co-Authored-By: Claude Opus 4.5 --- package.json | 2 +- src/components/Live/LiveKitBroadcast.jsx | 719 ++++++++++------------- 2 files changed, 308 insertions(+), 413 deletions(-) diff --git a/package.json b/package.json index 0138bf3..c2133e8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sociowire-frontend", "private": true, - "version": "0.0.91", + "version": "0.0.92", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/src/components/Live/LiveKitBroadcast.jsx b/src/components/Live/LiveKitBroadcast.jsx index 6167744..34bbb9d 100644 --- a/src/components/Live/LiveKitBroadcast.jsx +++ b/src/components/Live/LiveKitBroadcast.jsx @@ -4,14 +4,12 @@ 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://stream.sociowire.com"; /** - * LiveKit-based broadcast component - * Professional video streaming that works everywhere (5G, 4G, WiFi, firewalls) + * LiveKit-based broadcast component - Redesigned fullscreen experience */ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, initialTitle, initialCategory, initialSubCategory }) { const { username, token: authToken } = useAuth(); @@ -20,21 +18,20 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini const [roomName] = useState(() => `live_${userID}_${Date.now()}`); const [title, setTitle] = useState(initialTitle || ""); const [showConfirm, setShowConfirm] = useState(false); + const [showSettings, setShowSettings] = useState(!initialTitle); // Show settings if no title provided const [livePostId, setLivePostId] = useState(null); const liveCategoryOptions = FILTER_MAIN_CATEGORIES.filter((c) => c !== "All"); const [liveCategory, setLiveCategory] = useState(initialCategory || "Events"); const [liveSubCategory, setLiveSubCategory] = useState(initialSubCategory || "Live"); - // If title was provided from PostCreateModal, skip the form - const skipForm = !!(initialTitle && initialTitle.trim()); - // Timer state const [liveStartTime, setLiveStartTime] = useState(null); const [elapsedTime, setElapsedTime] = useState(0); - // Comments state (stored in the post so they persist after live) + // Comments state const [comments, setComments] = useState([]); const [commentInput, setCommentInput] = useState(""); + const [showComments, setShowComments] = useState(true); const commentsEndRef = useRef(null); const [liveCoords] = useState(() => { @@ -53,7 +50,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini const videoRef = useRef(null); - // Camera hook - handles device enumeration and swapping + // Camera hook const { mainStream, isSwapped, @@ -66,11 +63,10 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini stopCameras, } = useDualCamera(); - // Handle real-time comments received via WebSocket + // Handle real-time comments via WebSocket const handleDataReceived = useCallback((data, participant) => { if (data?.type === "comment" && data?.comment) { setComments(prev => { - // Avoid duplicates if (prev.some(c => c.id === data.comment.id)) return prev; return [...prev, data.comment]; }); @@ -122,35 +118,24 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini // Start camera on mount useEffect(() => { startCameras(); - return () => { - stopCameras(); - }; + return () => stopCameras(); }, []); - // Update video element when stream changes + // Update video element 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(); + el.play().catch(() => {}); }, [mainStream]); - // Timer effect - update every second when live + // Timer useEffect(() => { if (!isLive || !liveStartTime) return; - const interval = setInterval(() => { setElapsedTime(Math.floor((Date.now() - liveStartTime) / 1000)); }, 1000); - return () => clearInterval(interval); }, [isLive, liveStartTime]); @@ -159,30 +144,22 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini 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 + // Update LiveKit track when stream changes useEffect(() => { if (isLive && mainStream) { const newVideoTrack = mainStream.getVideoTracks()[0]; - if (newVideoTrack) { - replaceVideoTrack(newVideoTrack); - } + if (newVideoTrack) replaceVideoTrack(newVideoTrack); } }, [mainStream, isLive, replaceVideoTrack]); @@ -190,14 +167,9 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini if (!livePostId) return; const items = await fetchPostComments(livePostId, 50); if (Array.isArray(items)) { - // Merge with existing comments, keeping local/pending ones setComments(prev => { const serverIds = new Set(items.map(c => String(c.id))); const serverBodies = new Set(items.map(c => c.body?.toLowerCase().trim())); - // Keep local comments that: - // 1. Have underscore in ID (locally generated) - // 2. Are still pending or failed - // 3. Don't have matching body in server response const localOnly = prev.filter(c => { const id = String(c.id); const isLocal = id.includes('_'); @@ -210,23 +182,16 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini } }, [livePostId]); - // Initial load + fallback polling (less frequent since we use WebSocket) useEffect(() => { if (!isLive || !livePostId) return; let active = true; - // Initial load refreshComments(); - // Fallback poll every 15s in case WebSocket misses something const timer = setInterval(() => { if (active) refreshComments(); }, 15000); - return () => { - active = false; - clearInterval(timer); - }; + return () => { active = false; clearInterval(timer); }; }, [isLive, livePostId, refreshComments]); - // Add a comment (broadcast via WebSocket + persist to API with retry) const handleAddComment = useCallback(async () => { const body = commentInput.trim(); if (!body || !livePostId) return; @@ -236,73 +201,41 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini const newComment = { id: commentId, body, - author: username || "You", + username: username || "You", created_at: new Date().toISOString(), - _pending: true, // Mark as pending save + _pending: true, }; - // Show locally immediately setComments(prev => [...prev, newComment]); + if (sendData) sendData({ type: "comment", comment: newComment }); - // Broadcast via WebSocket to all viewers (real-time) - if (sendData) { - sendData({ type: "comment", comment: newComment }); - } - - // Persist to API with retry let saved = false; for (let attempt = 0; attempt < 3 && !saved; attempt++) { if (attempt > 0) await new Promise(r => setTimeout(r, 1000 * attempt)); const res = await createPostComment(livePostId, body); if (res?.ok) { saved = true; - // Update comment to mark as saved - setComments(prev => prev.map(c => - c.id === commentId ? { ...c, _pending: false } : c - )); + setComments(prev => prev.map(c => c.id === commentId ? { ...c, _pending: false } : c)); } } if (!saved) { - console.warn("[Live] Failed to save comment after 3 attempts"); - // Mark as failed but keep visible - setComments(prev => prev.map(c => - c.id === commentId ? { ...c, _failed: true } : c - )); + setComments(prev => prev.map(c => c.id === commentId ? { ...c, _failed: true } : c)); } }, [commentInput, livePostId, username, sendData]); const handleGoLive = useCallback(async () => { - // Prevent double-clicks and duplicate calls - if (isConnecting || isLive || livePostId) { - return; - } - 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; - } + if (isConnecting || isLive || livePostId) return; + if (!authToken) { alert("Please log in to start a live stream."); return; } + if (!title.trim()) { alert("Please enter a title"); setShowSettings(true); 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, - }); + onStreamCreated({ roomName, title: title.trim(), coords: liveCoords }); } try { @@ -330,36 +263,16 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini 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, - isConnecting, - isLive, - livePostId, - ]); + }, [title, mainStream, startBroadcast, roomName, liveCoords, onStreamCreated, authToken, username, liveCategory, liveSubCategory, isConnecting, isLive, livePostId]); const handleEndStream = useCallback(async (save) => { await stopBroadcast(); stopCameras(); - // Stop egress recording and get video key let videoKey = ""; let thumbnailKey = ""; try { @@ -372,7 +285,6 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini 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); @@ -380,7 +292,6 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini if (livePostId) { try { - // Include comments in finalize request const res = await fetch(`${API_BASE}/api/live/finalize`, { method: "POST", headers: { @@ -402,318 +313,302 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated, ini 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?.(); - } - }; + }, [stopBroadcast, stopCameras, onClose, livePostId, authToken, roomName]); return ( -
-
- {/* Header */} -
-
- - {isLive ? "LIVE" : "Go Live"} - {isLive && ( +
+ {/* Fullscreen Video */} +
+ {mainStream ? ( +
- - {/* Content */} -
- {/* Video preview */} -
-
- {mainStream ? ( -
-
- - {/* Side panel */} -
- {!isLive && !skipForm && ( -
- - setTitle(e.target.value)} - maxLength={100} - /> - - - - -
- Using LiveKit - works on 5G/4G/WiFi -
-
- )} - - {!isLive && skipForm && ( -
-

{title}

-
- - {liveCategory} · {liveSubCategory} - -
-
- Camera ready - tap Go Live to start -
-
- )} - - {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 -

- )} -
- - - -
-
-
)}
+ + {/* Gradient overlays */} +
+
+ + {/* Top bar */} +
+
+ {isLive ? ( + <> +
+ + LIVE +
+
+ + {viewerCount} +
+
+ + {formatTime(elapsedTime)} +
+ + ) : ( +
+ + Go Live +
+ )} +
+ + +
+ + {/* Camera controls (right side) */} +
+ {hasDualCameras && ( + + )} + {!isLive && ( + + )} + {isLive && ( + + )} +
+ + {/* Pre-live: Settings panel */} + {!isLive && showSettings && ( +
+
+
+

Stream Settings

+ +
+ setTitle(e.target.value)} + maxLength={100} + /> +
+ + +
+
+
+ )} + + {/* Pre-live: Title display (when settings hidden) */} + {!isLive && !showSettings && title && ( +
+
+

{title}

+

{liveCategory} · {liveSubCategory}

+
+
+ )} + + {/* Live: Comments section */} + {isLive && showComments && ( +
+
+ {comments.map((c, idx) => ( +
+
+ {(c.username || "?")[0]?.toUpperCase()} +
+
+ {c.username || "User"} +

{c.body}

+
+
+ ))} +
+
+
+ setCommentInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAddComment()} + disabled={!livePostId} + /> + +
+
+ )} + + {/* Live: Title display */} + {isLive && !showComments && ( +
+
+

{title}

+

{comments.length} comments

+
+
+ )} + + {/* Error display */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Bottom action button */} +
+ {isLive ? ( + + ) : ( + + )} +
+ + {/* End stream confirmation */} + {showConfirm && ( +
+
+
+ +
+

End Stream?

+

+ Your live stream will end for all {viewerCount} viewers. +

+ {comments.length > 0 && ( +

+ + {comments.length} comments will be saved +

+ )} +
+ + + +
+
+
+ )}
); }