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:
parent
6bec96cc19
commit
bacf7d9df3
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue