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 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-20 07:20:58 +00:00
parent 6bec96cc19
commit bacf7d9df3
2 changed files with 308 additions and 413 deletions

View File

@ -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",

View File

@ -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 (
<div className="sw-broadcast-modal" onClick={handleBackdropClick}>
<div className="sw-broadcast-container">
{/* Header */}
<div className="sw-broadcast-header">
<div className="sw-broadcast-header-left">
<i className="fa-solid fa-broadcast-tower" />
<span>{isLive ? "LIVE" : "Go Live"}</span>
{isLive && (
<div className="fixed inset-0 z-[9999] bg-black">
{/* Fullscreen Video */}
<div className="absolute inset-0">
{mainStream ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-white/60">
{cameraStarting ? (
<>
<div className="sw-broadcast-live-badge">
<span className="sw-broadcast-dot" />
<span>{viewerCount} watching</span>
</div>
<div className="sw-broadcast-timer">
<i className="fa-solid fa-clock" />
<span>{formatTime(elapsedTime)}</span>
</div>
<i className="fa-solid fa-spinner fa-spin text-5xl mb-4" />
<span>Starting camera...</span>
</>
) : (
<>
<i className="fa-solid fa-video-slash text-5xl mb-4 opacity-50" />
<span>Camera unavailable</span>
</>
)}
</div>
<button
type="button"
className="sw-broadcast-close"
onClick={() => {
if (isLive) {
setShowConfirm(true);
} else {
stopCameras();
onClose?.();
}
}}
>
<i className="fa-solid fa-times" />
</button>
</div>
{/* Content */}
<div className="sw-broadcast-content">
{/* Video preview */}
<div className="sw-broadcast-video-area">
<div className="sw-broadcast-main-video">
{mainStream ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
className="sw-broadcast-video"
/>
) : (
<div className="sw-broadcast-video-placeholder">
{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>
)}
{isLive && (
<div className="sw-broadcast-live-indicator">
<span className="sw-broadcast-dot" />
<span>LIVE</span>
</div>
)}
{/* Camera controls overlay */}
<div className="sw-broadcast-controls-overlay">
{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>
{/* Side panel */}
<div className="sw-broadcast-side">
{!isLive && !skipForm && (
<div className="sw-broadcast-form">
<label className="sw-broadcast-label">Stream Title</label>
<input
type="text"
className="sw-broadcast-input"
placeholder="What's happening?"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={100}
/>
<label className="sw-broadcast-label">Category</label>
<select
className="sw-broadcast-input"
value={liveCategory}
onChange={(e) => setLiveCategory(e.target.value)}
>
{liveCategoryOptions.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<label className="sw-broadcast-label">Subcategory</label>
<select
className="sw-broadcast-input"
value={liveSubCategory}
onChange={(e) => setLiveSubCategory(e.target.value)}
>
{subCategoryOptions.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<div className="sw-broadcast-hint">
Using LiveKit - works on 5G/4G/WiFi
</div>
</div>
)}
{!isLive && skipForm && (
<div className="sw-broadcast-ready">
<h3 className="sw-broadcast-title">{title}</h3>
<div className="sw-broadcast-stats">
<span>
<i className="fa-solid fa-tag" /> {liveCategory} · {liveSubCategory}
</span>
</div>
<div className="sw-broadcast-hint">
Camera ready - tap Go Live to start
</div>
</div>
)}
{isLive && (
<>
<div className="sw-broadcast-live-info">
<h3 className="sw-broadcast-title">{title}</h3>
<div className="sw-broadcast-stats">
<span>
<i className="fa-solid fa-eye" /> {viewerCount} viewers
</span>
<span>
<i className="fa-solid fa-clock" /> {formatTime(elapsedTime)}
</span>
</div>
</div>
{/* Comments section */}
<div className="sw-broadcast-comments">
<div className="sw-broadcast-comments-header">
<i className="fa-solid fa-comments" />
<span>Comments ({comments.length})</span>
</div>
<div className="sw-broadcast-comments-list">
{comments.length === 0 ? (
<div className="sw-broadcast-no-comments">
{livePostId ? "No comments yet" : "Connecting chat..."}
</div>
) : (
comments.map((c, idx) => (
<div
key={c.id || c.ID || c.created_at || c.createdAt || `${c.username || "user"}-${idx}`}
className="sw-broadcast-comment"
>
<span className="sw-broadcast-comment-author">{c.username || c.author || "User"}</span>
<span className="sw-broadcast-comment-text">{c.body || c.text || ""}</span>
</div>
))
)}
<div ref={commentsEndRef} />
</div>
<div className="sw-broadcast-comment-input-row">
<input
type="text"
className="sw-broadcast-comment-input"
placeholder={livePostId ? "Add a comment..." : "Chat starting..."}
value={commentInput}
onChange={(e) => setCommentInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
maxLength={200}
disabled={!livePostId}
/>
<button
type="button"
className="sw-broadcast-comment-send"
onClick={handleAddComment}
disabled={!commentInput.trim() || !livePostId}
>
<i className="fa-solid fa-paper-plane" />
</button>
</div>
</div>
</>
)}
{error && (
<div className="sw-broadcast-error">
<i className="fa-solid fa-exclamation-triangle" />
<span>{error}</span>
</div>
)}
<div className="sw-broadcast-actions">
{isLive ? (
<button
type="button"
className="sw-broadcast-btn danger"
onClick={() => setShowConfirm(true)}
>
<i className="fa-solid fa-stop" />
<span>End Stream</span>
</button>
) : (
<button
type="button"
className="sw-broadcast-btn primary"
onClick={handleGoLive}
disabled={isConnecting || !mainStream}
>
{isConnecting ? (
<>
<i className="fa-solid fa-spinner fa-spin" />
<span>Connecting...</span>
</>
) : (
<>
<i className="fa-solid fa-broadcast-tower" />
<span>Go Live</span>
</>
)}
</button>
)}
</div>
</div>
</div>
{/* Confirm dialog */}
{showConfirm && (
<div className="sw-broadcast-confirm-overlay">
<div className="sw-broadcast-confirm">
<h3>End Stream?</h3>
<p>Your live stream will end for all viewers.</p>
{comments.length > 0 && (
<p className="sw-broadcast-confirm-comments">
<i className="fa-solid fa-comments" /> {comments.length} comments will be saved
</p>
)}
<div className="sw-broadcast-confirm-actions">
<button
type="button"
className="sw-broadcast-btn secondary"
onClick={() => setShowConfirm(false)}
>
Cancel
</button>
<button
type="button"
className="sw-broadcast-btn danger"
onClick={() => handleEndStream(false)}
>
Discard
</button>
<button
type="button"
className="sw-broadcast-btn primary"
onClick={() => handleEndStream(true)}
>
Save
</button>
</div>
</div>
</div>
)}
</div>
{/* Gradient overlays */}
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-black/70 to-transparent pointer-events-none" />
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />
{/* Top bar */}
<div className="absolute top-0 left-0 right-0 flex items-center justify-between p-4 z-10">
<div className="flex items-center gap-3">
{isLive ? (
<>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-600 text-white text-sm font-bold shadow-lg shadow-red-600/50">
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
LIVE
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/50 backdrop-blur-sm text-white text-sm">
<i className="fa-solid fa-eye" />
<span>{viewerCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/50 backdrop-blur-sm text-white text-sm font-mono">
<i className="fa-solid fa-clock" />
<span>{formatTime(elapsedTime)}</span>
</div>
</>
) : (
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-sm text-white font-semibold">
<i className="fa-solid fa-broadcast-tower text-cyan-400" />
<span>Go Live</span>
</div>
)}
</div>
<button
className="w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-red-500/50 transition-colors"
onClick={() => isLive ? setShowConfirm(true) : (stopCameras(), onClose?.())}
>
<i className="fa-solid fa-times text-lg" />
</button>
</div>
{/* Camera controls (right side) */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex flex-col gap-3 z-10">
{hasDualCameras && (
<button
className="w-12 h-12 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-white/20 transition-colors"
onClick={switchCamera}
disabled={isSwapping}
>
{isSwapping ? (
<i className="fa-solid fa-spinner fa-spin" />
) : (
<i className="fa-solid fa-camera-rotate text-lg" />
)}
</button>
)}
{!isLive && (
<button
className="w-12 h-12 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-white/20 transition-colors"
onClick={() => setShowSettings(!showSettings)}
>
<i className="fa-solid fa-gear text-lg" />
</button>
)}
{isLive && (
<button
className={`w-12 h-12 rounded-full backdrop-blur-sm text-white flex items-center justify-center transition-colors ${showComments ? 'bg-cyan-500/50' : 'bg-black/50 hover:bg-white/20'}`}
onClick={() => setShowComments(!showComments)}
>
<i className="fa-solid fa-comments text-lg" />
</button>
)}
</div>
{/* Pre-live: Settings panel */}
{!isLive && showSettings && (
<div className="absolute left-4 right-4 bottom-32 z-10">
<div className="bg-black/70 backdrop-blur-xl rounded-2xl p-4 border border-white/10">
<div className="flex items-center justify-between mb-3">
<h3 className="text-white font-bold">Stream Settings</h3>
<button
className="text-white/60 hover:text-white"
onClick={() => setShowSettings(false)}
>
<i className="fa-solid fa-chevron-down" />
</button>
</div>
<input
type="text"
className="w-full px-4 py-3 mb-3 rounded-xl bg-white/10 border border-white/20 text-white placeholder:text-white/40 focus:outline-none focus:border-cyan-500"
placeholder="Stream title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={100}
/>
<div className="flex gap-2">
<select
className="flex-1 px-3 py-2 rounded-xl bg-white/10 border border-white/20 text-white focus:outline-none focus:border-cyan-500"
value={liveCategory}
onChange={(e) => setLiveCategory(e.target.value)}
>
{liveCategoryOptions.map((opt) => (
<option key={opt} value={opt} className="bg-gray-900">{opt}</option>
))}
</select>
<select
className="flex-1 px-3 py-2 rounded-xl bg-white/10 border border-white/20 text-white focus:outline-none focus:border-cyan-500"
value={liveSubCategory}
onChange={(e) => setLiveSubCategory(e.target.value)}
>
{subCategoryOptions.map((opt) => (
<option key={opt} value={opt} className="bg-gray-900">{opt}</option>
))}
</select>
</div>
</div>
</div>
)}
{/* Pre-live: Title display (when settings hidden) */}
{!isLive && !showSettings && title && (
<div className="absolute left-4 right-4 bottom-32 z-10">
<div className="bg-black/50 backdrop-blur-sm rounded-xl px-4 py-3 border border-white/10">
<h3 className="text-white font-bold text-lg truncate">{title}</h3>
<p className="text-white/60 text-sm">{liveCategory} · {liveSubCategory}</p>
</div>
</div>
)}
{/* Live: Comments section */}
{isLive && showComments && (
<div className="absolute left-4 right-20 bottom-32 max-h-[40vh] z-10 flex flex-col">
<div className="flex-1 overflow-y-auto space-y-2 mb-3 scrollbar-hide">
{comments.map((c, idx) => (
<div
key={c.id || idx}
className={`flex items-start gap-2 px-3 py-2 rounded-xl bg-black/50 backdrop-blur-sm ${c._pending ? 'opacity-60' : ''}`}
>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-cyan-500 to-blue-500 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{(c.username || "?")[0]?.toUpperCase()}
</div>
<div className="min-w-0">
<span className="text-cyan-400 text-sm font-semibold">{c.username || "User"}</span>
<p className="text-white text-sm break-words">{c.body}</p>
</div>
</div>
))}
<div ref={commentsEndRef} />
</div>
<div className="flex gap-2">
<input
type="text"
className="flex-1 px-4 py-3 rounded-full bg-black/50 backdrop-blur-sm border border-white/20 text-white placeholder:text-white/40 focus:outline-none focus:border-cyan-500"
placeholder={livePostId ? "Say something..." : "Connecting..."}
value={commentInput}
onChange={(e) => setCommentInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
disabled={!livePostId}
/>
<button
className="w-12 h-12 rounded-full bg-cyan-500 text-white flex items-center justify-center disabled:opacity-50"
onClick={handleAddComment}
disabled={!commentInput.trim() || !livePostId}
>
<i className="fa-solid fa-paper-plane" />
</button>
</div>
</div>
)}
{/* Live: Title display */}
{isLive && !showComments && (
<div className="absolute left-4 right-20 bottom-32 z-10">
<div className="bg-black/50 backdrop-blur-sm rounded-xl px-4 py-3">
<h3 className="text-white font-bold text-lg truncate">{title}</h3>
<p className="text-white/60 text-sm">{comments.length} comments</p>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="absolute left-4 right-4 bottom-24 z-10">
<div className="bg-red-500/20 backdrop-blur-sm border border-red-500/50 rounded-xl px-4 py-3 text-red-300 text-sm">
<i className="fa-solid fa-exclamation-triangle mr-2" />
{error}
</div>
</div>
)}
{/* Bottom action button */}
<div className="absolute bottom-6 left-4 right-4 z-10">
{isLive ? (
<button
className="w-full py-4 rounded-2xl bg-gradient-to-r from-red-600 to-rose-600 text-white font-bold text-lg shadow-lg shadow-red-600/30 flex items-center justify-center gap-3 active:scale-[0.98] transition-transform"
onClick={() => setShowConfirm(true)}
>
<i className="fa-solid fa-stop" />
End Stream
</button>
) : (
<button
className="w-full py-4 rounded-2xl bg-gradient-to-r from-red-600 to-rose-600 text-white font-bold text-lg shadow-lg shadow-red-600/30 flex items-center justify-center gap-3 disabled:opacity-50 active:scale-[0.98] transition-transform"
onClick={handleGoLive}
disabled={isConnecting || !mainStream}
>
{isConnecting ? (
<>
<i className="fa-solid fa-spinner fa-spin" />
Connecting...
</>
) : (
<>
<i className="fa-solid fa-broadcast-tower" />
Go Live
</>
)}
</button>
)}
</div>
{/* End stream confirmation */}
{showConfirm && (
<div className="absolute inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-6">
<div className="bg-surface-900 rounded-3xl p-6 max-w-sm w-full border border-white/10 shadow-2xl">
<div className="w-16 h-16 rounded-full bg-red-500/20 flex items-center justify-center mx-auto mb-4">
<i className="fa-solid fa-stop text-3xl text-red-400" />
</div>
<h3 className="text-xl font-bold text-white text-center mb-2">End Stream?</h3>
<p className="text-surface-400 text-center mb-4">
Your live stream will end for all {viewerCount} viewers.
</p>
{comments.length > 0 && (
<p className="text-cyan-400 text-center text-sm mb-4">
<i className="fa-solid fa-comments mr-2" />
{comments.length} comments will be saved
</p>
)}
<div className="flex flex-col gap-2">
<button
className="w-full py-3 rounded-xl bg-gradient-to-r from-emerald-600 to-green-600 text-white font-bold"
onClick={() => handleEndStream(true)}
>
<i className="fa-solid fa-download mr-2" />
Save & End
</button>
<button
className="w-full py-3 rounded-xl bg-red-500/20 border border-red-500/50 text-red-400 font-bold"
onClick={() => handleEndStream(false)}
>
<i className="fa-solid fa-trash mr-2" />
Discard
</button>
<button
className="w-full py-3 rounded-xl bg-white/10 text-white font-bold"
onClick={() => setShowConfirm(false)}
>
Continue Streaming
</button>
</div>
</div>
</div>
)}
</div>
);
}