diff --git a/src/components/Live/Live.css b/src/components/Live/Live.css index d7eb135..bab3f11 100644 --- a/src/components/Live/Live.css +++ b/src/components/Live/Live.css @@ -551,6 +551,20 @@ body[data-theme="light"] .sw-video-modal-subtitle { box-shadow: 0 0 15px rgba(239, 68, 68, 0.5); } +.sw-broadcast-timer { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.7rem; + border-radius: 999px; + background: rgba(56, 189, 248, 0.2); + border: 1px solid rgba(56, 189, 248, 0.4); + color: #93c5fd; + font-size: 0.72rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + .sw-broadcast-dot { width: 8px; height: 8px; @@ -1109,6 +1123,150 @@ body[data-theme="light"] .sw-video-modal-subtitle { font-size: 0.85rem; } +.sw-broadcast-confirm-comments { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + font-size: 0.8rem; + color: #93c5fd; + margin-bottom: 0.5rem; +} + +/* ===== COMMENTS SECTION ===== */ +.sw-broadcast-comments { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + margin-top: 0.75rem; + border-top: 1px solid rgba(56, 189, 248, 0.15); + padding-top: 0.75rem; +} + +.sw-broadcast-comments-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} + +.sw-broadcast-comments-header i { + color: #38bdf8; +} + +.sw-broadcast-comments-list { + flex: 1; + min-height: 80px; + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.35rem; + padding-right: 0.25rem; +} + +.sw-broadcast-comments-list::-webkit-scrollbar { + width: 4px; +} + +.sw-broadcast-comments-list::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.5); + border-radius: 2px; +} + +.sw-broadcast-comments-list::-webkit-scrollbar-thumb { + background: rgba(56, 189, 248, 0.4); + border-radius: 2px; +} + +.sw-broadcast-no-comments { + display: flex; + align-items: center; + justify-content: center; + height: 60px; + font-size: 0.8rem; + color: #64748b; + font-style: italic; +} + +.sw-broadcast-comment { + display: flex; + gap: 0.4rem; + padding: 0.4rem 0.5rem; + border-radius: 8px; + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(56, 189, 248, 0.1); +} + +.sw-broadcast-comment-author { + font-size: 0.75rem; + font-weight: 700; + color: #38bdf8; + flex-shrink: 0; +} + +.sw-broadcast-comment-author::after { + content: ":"; +} + +.sw-broadcast-comment-text { + font-size: 0.8rem; + color: #e5e7eb; + word-break: break-word; +} + +.sw-broadcast-comment-input-row { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.sw-broadcast-comment-input { + flex: 1; + padding: 0.5rem 0.7rem; + border-radius: 8px; + border: 1px solid rgba(56, 189, 248, 0.3); + background: rgba(15, 23, 42, 0.8); + color: #f8fafc; + font-size: 0.85rem; +} + +.sw-broadcast-comment-input:focus { + outline: none; + border-color: rgba(56, 189, 248, 0.6); +} + +.sw-broadcast-comment-input::placeholder { + color: #64748b; +} + +.sw-broadcast-comment-send { + width: 36px; + height: 36px; + border-radius: 8px; + border: none; + background: linear-gradient(135deg, #38bdf8, #0ea5e9); + color: #fff; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.sw-broadcast-comment-send:hover:not(:disabled) { + transform: scale(1.05); +} + +.sw-broadcast-comment-send:disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* ===== VIEWER MODAL ===== */ .sw-viewer-modal { position: fixed; diff --git a/src/components/Live/LiveKitBroadcast.jsx b/src/components/Live/LiveKitBroadcast.jsx index b50fe79..888e520 100644 --- a/src/components/Live/LiveKitBroadcast.jsx +++ b/src/components/Live/LiveKitBroadcast.jsx @@ -21,6 +21,16 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { 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) { @@ -44,7 +54,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { viewerCount, startBroadcast, stopBroadcast, - toggleAudio, + replaceVideoTrack, } = useLiveKit({ roomName, userID, @@ -84,6 +94,29 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { } }, [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; @@ -98,9 +131,15 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { audio: true, }); - // Stop old tracks + // Stop old video tracks only (keep audio reference for LiveKit) if (localStream) { - localStream.getTracks().forEach((t) => t.stop()); + 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 @@ -118,7 +157,22 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { } finally { setIsSwitchingCamera(false); } - }, [facingMode, localStream, isSwitchingCamera]); + }, [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) { @@ -138,6 +192,10 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { const videoTrack = localStream.getVideoTracks()[0]; await startBroadcast(videoTrack, true); + // Start timer + setLiveStartTime(Date.now()); + setElapsedTime(0); + if (onStreamCreated) { onStreamCreated({ roomName, @@ -207,6 +265,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { if (livePostId) { try { + // Include comments in finalize request const res = await fetch(`${API_BASE}/api/live/finalize`, { method: "POST", headers: { @@ -217,6 +276,11 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { 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) { @@ -237,7 +301,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { } } onClose?.(); - }, [stopBroadcast, localStream, onClose, livePostId, authToken, roomName]); + }, [stopBroadcast, localStream, onClose, livePostId, authToken, roomName, comments]); const handleBackdropClick = (e) => { if (e.target === e.currentTarget && !isLive) { @@ -257,10 +321,16 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { {isLive ? "LIVE" : "Go Live"} {isLive && ( -
- - {viewerCount} watching -
+ <> +
+ + {viewerCount} watching +
+
+ + {formatTime(elapsedTime)} +
+ )} + + + )} {error && ( @@ -404,6 +521,11 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {

End Stream?

Your live stream will end for all viewers.

+ {comments.length > 0 && ( +

+ {comments.length} comments will be saved +

+ )}
diff --git a/src/components/Live/useLiveKit.js b/src/components/Live/useLiveKit.js index 198c7bc..5521a73 100644 --- a/src/components/Live/useLiveKit.js +++ b/src/components/Live/useLiveKit.js @@ -208,6 +208,32 @@ export function useLiveKit({ roomName, userID, username, token: authToken }) { } }, []); + // Replace video track (for camera switch) + const replaceVideoTrack = useCallback(async (newTrack) => { + if (!roomRef.current || !newTrack) return false; + + try { + // Unpublish old track + if (localVideoTrackRef.current) { + await roomRef.current.localParticipant.unpublishTrack(localVideoTrackRef.current); + localVideoTrackRef.current.stop(); + } + + // Publish new track + await roomRef.current.localParticipant.publishTrack(newTrack, { + name: "camera", + simulcast: true, + videoCodec: "vp8", + }); + localVideoTrackRef.current = newTrack; + console.log("[LiveKit] Video track replaced"); + return true; + } catch (err) { + console.error("[LiveKit] Failed to replace video track:", err); + return false; + } + }, []); + // Cleanup on unmount useEffect(() => { return () => { @@ -227,6 +253,7 @@ export function useLiveKit({ roomName, userID, username, token: authToken }) { stopBroadcast, toggleAudio, toggleVideo, + replaceVideoTrack, room: roomRef.current, }; }