sw-fe/src/components/Live/LiveKitBroadcast.jsx

559 lines
18 KiB
JavaScript

import { useState, useRef, useEffect, useCallback } from "react";
import { useLiveKit } from "./useLiveKit";
import { useAuth } from "../../auth/AuthContext";
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 [localStream, setLocalStream] = useState(null);
const [showConfirm, setShowConfirm] = useState(false);
const [livePostId, setLivePostId] = useState(null);
const [facingMode, setFacingMode] = useState("user");
const [isSwitchingCamera, setIsSwitchingCamera] = useState(false);
// Timer state
const [liveStartTime, setLiveStartTime] = useState(null);
const [elapsedTime, setElapsedTime] = useState(0);
// Comments state
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);
const {
isLive,
isConnecting,
error,
viewerCount,
startBroadcast,
stopBroadcast,
replaceVideoTrack,
} = useLiveKit({
roomName,
userID,
username,
token: authToken,
});
// Start camera on mount
useEffect(() => {
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user", width: 1280, height: 720 },
audio: true,
});
setLocalStream(stream);
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (err) {
console.error("[LiveKit] Camera error:", err);
}
};
startCamera();
return () => {
if (localStream) {
localStream.getTracks().forEach((t) => t.stop());
}
};
}, []);
// Update video element when stream changes
useEffect(() => {
if (videoRef.current && localStream) {
videoRef.current.srcObject = localStream;
}
}, [localStream]);
// 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 (isSwitchingCamera) return;
setIsSwitchingCamera(true);
try {
const newFacingMode = facingMode === "user" ? "environment" : "user";
// Get new stream with switched camera
const newStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: newFacingMode, width: 1280, height: 720 },
audio: true,
});
// Stop old video tracks only (keep audio reference for LiveKit)
if (localStream) {
localStream.getVideoTracks().forEach((t) => t.stop());
}
// If live, replace the video track in LiveKit
if (isLive) {
const newVideoTrack = newStream.getVideoTracks()[0];
await replaceVideoTrack(newVideoTrack);
}
// Update state
setLocalStream(newStream);
setFacingMode(newFacingMode);
// Update video element
if (videoRef.current) {
videoRef.current.srcObject = newStream;
}
console.log("[LiveKit] Switched to", newFacingMode, "camera");
} catch (err) {
console.error("[LiveKit] Failed to switch camera:", err);
} finally {
setIsSwitchingCamera(false);
}
}, [facingMode, localStream, isSwitchingCamera, isLive, replaceVideoTrack]);
// Add a comment
const handleAddComment = useCallback(() => {
if (!commentInput.trim()) return;
const newComment = {
id: Date.now(),
author: username || "Host",
text: commentInput.trim(),
timestamp: Date.now(),
};
setComments((prev) => [...prev, newComment]);
setCommentInput("");
}, [commentInput, username]);
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 (!localStream) {
alert("Camera not ready");
return;
}
// Get video track from stream
const videoTrack = localStream.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",
category: "EVENT",
sub_category: "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, localStream, startBroadcast, roomName, liveCoords, onStreamCreated, authToken, username]);
const handleEndStream = useCallback(async (save) => {
await stopBroadcast();
if (localStream) {
localStream.getTracks().forEach((t) => t.stop());
}
// Stop egress recording and get video key
let videoKey = "";
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 || "";
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,
comments: save ? comments.map((c) => ({
author: c.author,
text: c.text,
timestamp: c.timestamp,
})) : [],
}),
});
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, localStream, onClose, livePostId, authToken, roomName, comments]);
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget && !isLive) {
if (localStream) {
localStream.getTracks().forEach((t) => t.stop());
}
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 {
if (localStream) {
localStream.getTracks().forEach((t) => t.stop());
}
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">
{localStream ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
className="sw-broadcast-video"
/>
) : (
<div className="sw-broadcast-video-placeholder">
<i className="fa-solid fa-spinner fa-spin" />
<span>Starting camera...</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">
<button
type="button"
className="sw-broadcast-control-btn"
onClick={switchCamera}
disabled={isSwitchingCamera}
title={facingMode === "user" ? "Switch to back camera" : "Switch to front camera"}
>
{isSwitchingCamera ? (
<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}
/>
<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">
No comments yet
</div>
) : (
comments.map((c) => (
<div key={c.id} className="sw-broadcast-comment">
<span className="sw-broadcast-comment-author">{c.author}</span>
<span className="sw-broadcast-comment-text">{c.text}</span>
</div>
))
)}
<div ref={commentsEndRef} />
</div>
<div className="sw-broadcast-comment-input-row">
<input
type="text"
className="sw-broadcast-comment-input"
placeholder="Add a comment..."
value={commentInput}
onChange={(e) => setCommentInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
maxLength={200}
/>
<button
type="button"
className="sw-broadcast-comment-send"
onClick={handleAddComment}
disabled={!commentInput.trim()}
>
<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 || !localStream}
>
{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>
);
}