632 lines
20 KiB
JavaScript
632 lines
20 KiB
JavaScript
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 (
|
|
<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="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>
|
|
</>
|
|
)}
|
|
</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 && (
|
|
<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 && (
|
|
<>
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|