diff --git a/public/service-worker.js b/public/service-worker.js index 548c1c7..a925c1c 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,5 +1,5 @@ // public/service-worker.js -const CACHE_NAME = 'sociowire-v9'; +const CACHE_NAME = 'sociowire-v11'; const PRECACHE = [ '/icons/icon-192x192.png', '/icons/icon-512x512.png', diff --git a/src/components/Live/LiveBroadcastModal.jsx b/src/components/Live/LiveBroadcastModal.jsx deleted file mode 100644 index 63429ed..0000000 --- a/src/components/Live/LiveBroadcastModal.jsx +++ /dev/null @@ -1,481 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback } from "react"; -import { useDualCamera } from "./useDualCamera"; -import { useWebRTCBroadcast } from "./useWebRTCBroadcast"; -import { useAuth } from "../../auth/AuthContext"; -import { CREATE_CATEGORY_MAP } from "../Map/mapConfig"; -import "./Live.css"; - -const MAIN_CATEGORIES = ["News", "Events", "Friends", "Market"]; - -/** - * LiveBroadcastModal - Host UI for live streaming - * - * Features: - * - Dual camera preview (main + self PiP) - * - Go Live / End Stream controls - * - Title input - * - Viewer count - * - Join request management - * - Participant grid - */ -export default function LiveBroadcastModal({ coords, onClose, onStreamCreated }) { - const { username, token } = useAuth(); - const userID = username || `anon_${Date.now()}`; - - // Generate stream ID - const [streamID] = useState(() => `live_${userID}_${Date.now()}`); - - // Form state - const [title, setTitle] = useState(""); - const [category, setCategory] = useState("News"); - const [subCategory, setSubCategory] = useState(CREATE_CATEGORY_MAP.News[0]); - const [showEndConfirm, setShowEndConfirm] = useState(false); - const [saveAsPost, setSaveAsPost] = useState(true); - - // Video refs - const mainVideoRef = useRef(null); - const selfVideoRef = useRef(null); - const participantRefs = useRef({}); - - // Hooks - const { - mainStream, - selfStream, - isSwapped, - error: cameraError, - isStarting: cameraStarting, - isSwapping, - hasDualCameras, - startCameras, - swapCameras, - stopCameras, - toggleAudio, - } = useDualCamera(); - - const { - isLive, - isConnecting, - error: broadcastError, - viewerCount, - participants, - joinRequests, - startBroadcast, - stopBroadcast, - setStreamMeta, - approveJoinRequest, - denyJoinRequest, - kickParticipant, - } = useWebRTCBroadcast({ streamID, userID, username, token }); - - const [audioMuted, setAudioMuted] = useState(false); - - // Start cameras on mount - useEffect(() => { - startCameras(); - return () => { - stopCameras(); - }; - }, []); - - // Attach main stream to video element - useEffect(() => { - if (mainVideoRef.current && mainStream) { - mainVideoRef.current.srcObject = mainStream; - } - }, [mainStream]); - - // Attach self stream to PiP video element - useEffect(() => { - if (selfVideoRef.current && selfStream) { - selfVideoRef.current.srcObject = selfStream; - } - }, [selfStream]); - - // Attach participant streams - useEffect(() => { - participants.forEach((p) => { - const ref = participantRefs.current[p.userID]; - if (ref && p.stream) { - ref.srcObject = p.stream; - } - }); - }, [participants]); - - const handleGoLive = useCallback(async () => { - console.log("[LiveBroadcast] handleGoLive clicked, title:", title, "mainStream:", !!mainStream); - - // Remote log for debugging - fetch('/api/debug-log', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - level: 'log', - msg: `[LiveBroadcast] handleGoLive: title="${title}" hasMainStream=${!!mainStream} hasSelfStream=${!!selfStream}`, - ts: new Date().toISOString(), - src: 'broadcast' - }) - }).catch(() => {}); - - if (!title.trim()) { - alert("Please enter a title for your live stream"); - return; - } - - const meta = { - title: title.trim(), - lat: coords?.[1], - lon: coords?.[0], - category: category === "Events" ? "EVENT" : category.toUpperCase(), - sub_category: subCategory, - }; - - // Store metadata for post creation when stream ends - setStreamMeta(meta); - - console.log("[LiveBroadcast] Calling startBroadcast with meta:", meta); - await startBroadcast(mainStream, selfStream, meta); - - if (onStreamCreated) { - onStreamCreated({ streamID, title: title.trim(), coords, category, subCategory }); - } - }, [title, category, subCategory, mainStream, selfStream, coords, startBroadcast, setStreamMeta, streamID, onStreamCreated]); - - const handleEndStream = useCallback(() => { - setShowEndConfirm(true); - }, []); - - const confirmEndStream = useCallback(async () => { - await stopBroadcast(saveAsPost); - stopCameras(); - onClose?.(); - }, [saveAsPost, stopBroadcast, stopCameras, onClose]); - - const handleToggleAudio = useCallback(() => { - const newMuted = !audioMuted; - setAudioMuted(newMuted); - toggleAudio(newMuted); - }, [audioMuted, toggleAudio]); - - const handleBackdropClick = (e) => { - if (e.target === e.currentTarget && !isLive) { - stopCameras(); - onClose?.(); - } - }; - - const error = cameraError || broadcastError; - - return ( -
-
- {/* Header */} -
-
- - {isLive ? "LIVE" : "Go Live"} - {isLive && ( -
- - {viewerCount} watching -
- )} -
- -
- - {/* Main content */} -
- {/* Video preview area */} -
- {/* Main video (fullscreen) */} -
- {mainStream ? ( - <> -
-
- - {/* Side panel */} -
- {/* Title input (before going live) */} - {!isLive && ( -
- - setTitle(e.target.value)} - maxLength={100} - /> - -
-
- - -
-
- - -
-
- -
- Location: {coords ? `${coords[1].toFixed(4)}, ${coords[0].toFixed(4)}` : "Unknown"} -
-
- )} - - {/* Live info */} - {isLive && ( -
-

{title}

-
- {viewerCount} viewers - {participants.length} participants -
-
- )} - - {/* Join requests (when live) */} - {isLive && joinRequests.length > 0 && ( -
-

Join Requests

- {joinRequests.map((req) => ( -
- {req.username} -
- - -
-
- ))} -
- )} - - {/* Error display */} - {error && ( -
- - {error} -
- )} - - {/* Actions */} -
- {!isLive ? ( - - ) : ( - - )} -
-
-
- - {/* End stream confirmation modal */} - {showEndConfirm && ( -
-
-

End Stream?

-

Your live stream will end for all viewers.

- - - -
- - -
-
-
- )} -
-
- ); -} diff --git a/src/components/Live/LiveKitBroadcast.jsx b/src/components/Live/LiveKitBroadcast.jsx index 5ba561b..cd110a0 100644 --- a/src/components/Live/LiveKitBroadcast.jsx +++ b/src/components/Live/LiveKitBroadcast.jsx @@ -7,7 +7,7 @@ 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.us4.sociowire.com"; +const LIVEKIT_API = "https://stream.sociowire.com"; /** * LiveKit-based broadcast component @@ -205,6 +205,10 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { }, [commentInput, livePostId, refreshComments]); 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; @@ -279,6 +283,9 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { username, liveCategory, liveSubCategory, + isConnecting, + isLive, + livePostId, ]); const handleEndStream = useCallback(async (save) => { diff --git a/src/components/Live/LiveKitViewerModal.jsx b/src/components/Live/LiveKitViewerModal.jsx index b93802b..1604f37 100644 --- a/src/components/Live/LiveKitViewerModal.jsx +++ b/src/components/Live/LiveKitViewerModal.jsx @@ -3,8 +3,8 @@ import { Room, RoomEvent } from "livekit-client"; import { useAuth } from "../../auth/AuthContext"; import "./Live.css"; -const LIVEKIT_URL = "wss://live.us4.sociowire.com"; -const LIVEKIT_API = "https://stream.us4.sociowire.com"; +const LIVEKIT_URL = "wss://stream.sociowire.com"; +const LIVEKIT_API = "https://stream.sociowire.com"; function roomNameFromPost(post) { const raw = (post?.embed_url || post?.video_url || "").toString().trim(); diff --git a/src/components/Live/LiveViewerModal.jsx b/src/components/Live/LiveViewerModal.jsx deleted file mode 100644 index ef3d4eb..0000000 --- a/src/components/Live/LiveViewerModal.jsx +++ /dev/null @@ -1,337 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback } from "react"; -import maplibregl from "maplibre-gl"; -import { useWebRTCViewer } from "./useWebRTCViewer"; -import { useAuth } from "../../auth/AuthContext"; -import "./Live.css"; - -const MAPTILER_KEY = "DvwhzlPmC55ZK28nLtXL"; - -/** - * LiveViewerModal - Viewer UI for watching live streams - * - * Features: - * - Main video + presenter PiP - * - Participant grid - * - Request to join - * - Viewer count - * - Location mini-map - * - Share - */ -export default function LiveViewerModal({ streamID, streamData, onClose }) { - const { username, token, authenticated } = useAuth(); - const userID = username || `viewer_${Date.now()}`; - - // Video refs - const mainVideoRef = useRef(null); - const presenterVideoRef = useRef(null); - const localVideoRef = useRef(null); - const participantRefs = useRef({}); - const mapContainerRef = useRef(null); - const mapRef = useRef(null); - - // Viewer hook - const { - isConnected, - isConnecting, - error, - mainStream, - presenterStream, - participantStreams, - streamInfo, - viewerCount, - joinRequestStatus, - isParticipant, - localStream, - connect, - disconnect, - requestToJoin, - cancelJoinRequest, - leaveAsParticipant, - } = useWebRTCViewer({ streamID, userID, username, token }); - - // Connect on mount - useEffect(() => { - connect(); - return () => { - disconnect(); - }; - }, []); - - // Attach main stream - useEffect(() => { - if (mainVideoRef.current && mainStream) { - mainVideoRef.current.srcObject = mainStream; - } - }, [mainStream]); - - // Attach presenter stream to PiP - useEffect(() => { - if (presenterVideoRef.current && presenterStream) { - presenterVideoRef.current.srcObject = presenterStream; - } - }, [presenterStream]); - - // Attach local stream when participating - useEffect(() => { - if (localVideoRef.current && localStream) { - localVideoRef.current.srcObject = localStream; - } - }, [localStream]); - - // Attach participant streams - useEffect(() => { - participantStreams.forEach((p) => { - const ref = participantRefs.current[p.userID]; - if (ref && p.stream) { - ref.srcObject = p.stream; - } - }); - }, [participantStreams]); - - // Initialize mini map - useEffect(() => { - if (!mapContainerRef.current) return; - if (mapRef.current) return; - - const lat = streamData?.lat || streamInfo?.lat; - const lng = streamData?.lon || streamInfo?.lon; - - if (!lat || !lng) return; - - const map = new maplibregl.Map({ - container: mapContainerRef.current, - style: `https://api.maptiler.com/maps/streets-v2-dark/style.json?key=${MAPTILER_KEY}`, - center: [lng, lat], - zoom: 12, - attributionControl: false, - interactive: false, - }); - - // Add marker - const el = document.createElement("div"); - el.className = "sw-live-marker"; - el.innerHTML = ``; - - new maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(map); - - mapRef.current = map; - - return () => { - map.remove(); - mapRef.current = null; - }; - }, [streamData, streamInfo]); - - const handleShare = useCallback(() => { - const shareUrl = `${window.location.origin}/live/${streamID}`; - if (navigator.share) { - navigator.share({ - title: streamInfo?.title || "Live Stream", - text: "Watch this live stream!", - url: shareUrl, - }).catch(() => {}); - } else if (navigator.clipboard) { - navigator.clipboard.writeText(shareUrl); - // Could show toast here - } - }, [streamID, streamInfo]); - - const handleBackdropClick = (e) => { - if (e.target === e.currentTarget) { - disconnect(); - onClose?.(); - } - }; - - const handleClose = () => { - disconnect(); - onClose?.(); - }; - - const info = streamInfo || streamData || {}; - - return ( -
-
- {/* Header */} -
-
-
- - LIVE -
- {info.title || "Live Stream"} -
-
- - {viewerCount} - - -
-
- - {/* Main content */} -
- {/* Video area */} -
- {/* Main video */} -
- {isConnecting ? ( -
- - Connecting... -
- ) : error ? ( -
- - {error} -
- ) : mainStream ? ( -
-
- - {/* Side panel */} -
- {/* Stream info */} -
-
- - {info.broadcaster_name || "Host"} -
- {info.title &&

{info.title}

} -
- - {/* Mini map */} -
- - {/* Join section */} - {authenticated && !isParticipant && ( -
- {joinRequestStatus === null && ( - - )} - {joinRequestStatus === "pending" && ( -
- - Waiting for host approval... - -
- )} - {joinRequestStatus === "denied" && ( -
- - Join request denied -
- )} -
- )} - - {/* Leave participant button */} - {isParticipant && ( -
- -
- )} - - {/* Actions */} -
- -
-
-
-
-
- ); -} diff --git a/src/components/Live/index.js b/src/components/Live/index.js index ae1b4d4..9e054f4 100644 --- a/src/components/Live/index.js +++ b/src/components/Live/index.js @@ -1,11 +1,7 @@ export { default as CameraMiniMap } from "./CameraMiniMap"; export { default as CameraCard } from "./CameraCard"; export { default as VideoModal } from "./VideoModal"; -export { default as LiveBroadcastModal } from "./LiveBroadcastModal"; export { default as LiveKitBroadcast } from "./LiveKitBroadcast"; export { default as LiveKitViewerModal } from "./LiveKitViewerModal"; -export { default as LiveViewerModal } from "./LiveViewerModal"; export { useDualCamera } from "./useDualCamera"; -export { useWebRTCBroadcast } from "./useWebRTCBroadcast"; -export { useWebRTCViewer } from "./useWebRTCViewer"; export { useLiveKit } from "./useLiveKit"; diff --git a/src/components/Live/useLiveKit.js b/src/components/Live/useLiveKit.js index 54e75fc..0393f23 100644 --- a/src/components/Live/useLiveKit.js +++ b/src/components/Live/useLiveKit.js @@ -8,7 +8,7 @@ import { } from "livekit-client"; const LIVEKIT_URL = "wss://stream.sociowire.com"; -const LIVEKIT_API = "https://www.sociowire.com"; +const LIVEKIT_API = "https://stream.sociowire.com"; /** * Hook for LiveKit-based live streaming diff --git a/src/components/Live/useWebRTCBroadcast.js b/src/components/Live/useWebRTCBroadcast.js deleted file mode 100644 index 9e42648b..0000000 --- a/src/components/Live/useWebRTCBroadcast.js +++ /dev/null @@ -1,537 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from "react"; - -const STREAM_SERVER = "https://stream.sociowire.com"; -const API_BASE = (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, ""); - -// Remote debug logger - temporary -const remoteLog = async (level, ...args) => { - const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '); - console[level](...args); - try { - await fetch('/api/debug-log', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ level, msg, ts: new Date().toISOString(), src: 'broadcast' }) - }).catch(() => {}); - } catch {} -}; -const ICE_SERVERS = [ - { urls: "stun:stun.l.google.com:19302" }, - { - urls: "turns:turn.us2.sociowire.com:443", - username: "sociowire", - credential: "sociowire123", - }, -]; - -/** - * Hook for WebRTC broadcasting with multi-participant support - */ -export function useWebRTCBroadcast({ streamID, userID, username, token }) { - const [isLive, setIsLive] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [error, setError] = useState(null); - const [viewerCount, setViewerCount] = useState(0); - const [participants, setParticipants] = useState([]); // Other participants - const [joinRequests, setJoinRequests] = useState([]); // Pending join requests - - const pcRef = useRef(null); - const wsRef = useRef(null); - const participantPCsRef = useRef(new Map()); // userID -> PeerConnection for each participant - - // Connect to WebSocket for real-time events - const connectEventSocket = useCallback(() => { - if (!streamID) return; - - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const wsUrl = `${protocol}://stream.sociowire.com/api/streams/${streamID}/events`; - - try { - const ws = new WebSocket(wsUrl); - - ws.onopen = () => { - console.log("[Broadcast] Event socket connected"); - // Authenticate - ws.send(JSON.stringify({ type: "auth", token, userID, role: "host" })); - }; - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data); - handleEventMessage(msg); - } catch (err) { - console.error("[Broadcast] Failed to parse event:", err); - } - }; - - ws.onclose = () => { - console.log("[Broadcast] Event socket closed"); - }; - - ws.onerror = (err) => { - console.error("[Broadcast] Event socket error:", err); - }; - - wsRef.current = ws; - } catch (err) { - console.error("[Broadcast] Failed to connect event socket:", err); - } - }, [streamID, token, userID]); - - const handleEventMessage = useCallback((msg) => { - switch (msg.type) { - case "viewer_count": - setViewerCount(msg.count || 0); - break; - - case "join_request": - setJoinRequests((prev) => [ - ...prev.filter((r) => r.userID !== msg.userID), - { userID: msg.userID, username: msg.username, requestedAt: Date.now() }, - ]); - break; - - case "join_request_cancelled": - setJoinRequests((prev) => prev.filter((r) => r.userID !== msg.userID)); - break; - - case "participant_joined": - // Another user joined as co-host, set up peer connection to receive their stream - handleParticipantJoined(msg); - break; - - case "participant_left": - handleParticipantLeft(msg.userID); - break; - - case "participant_offer": - // Incoming offer from a participant - handleParticipantOffer(msg); - break; - - case "participant_ice": - // ICE candidate from participant - handleParticipantICE(msg); - break; - - default: - console.log("[Broadcast] Unknown event:", msg.type); - } - }, []); - - const handleParticipantJoined = useCallback(async (msg) => { - const { userID: participantID, username: participantName } = msg; - - // Add to participants list - setParticipants((prev) => [ - ...prev.filter((p) => p.userID !== participantID), - { userID: participantID, username: participantName, stream: null }, - ]); - - // Remove from join requests - setJoinRequests((prev) => prev.filter((r) => r.userID !== participantID)); - }, []); - - const handleParticipantLeft = useCallback((participantID) => { - setParticipants((prev) => prev.filter((p) => p.userID !== participantID)); - - // Close their peer connection - const pc = participantPCsRef.current.get(participantID); - if (pc) { - pc.close(); - participantPCsRef.current.delete(participantID); - } - }, []); - - const handleParticipantOffer = useCallback(async (msg) => { - const { userID: participantID, sdp } = msg; - - try { - const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); - - pc.ontrack = (event) => { - // Received participant's video/audio - setParticipants((prev) => - prev.map((p) => - p.userID === participantID ? { ...p, stream: event.streams[0] } : p - ) - ); - }; - - pc.onicecandidate = (event) => { - if (event.candidate && wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ - type: "ice_candidate", - targetUserID: participantID, - candidate: event.candidate, - }) - ); - } - }; - - await pc.setRemoteDescription({ type: "offer", sdp }); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - - participantPCsRef.current.set(participantID, pc); - - // Send answer back via WebSocket - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ - type: "participant_answer", - targetUserID: participantID, - sdp: answer.sdp, - }) - ); - } - } catch (err) { - console.error("[Broadcast] Failed to handle participant offer:", err); - } - }, []); - - const handleParticipantICE = useCallback((msg) => { - const { userID: participantID, candidate } = msg; - const pc = participantPCsRef.current.get(participantID); - if (pc && candidate) { - pc.addIceCandidate(new RTCIceCandidate(candidate)).catch((err) => { - console.error("[Broadcast] Failed to add ICE candidate:", err); - }); - } - }, []); - - // Start broadcasting - const startBroadcast = useCallback( - async (mainStream, selfStream, metadata = {}) => { - // Synchronous log first to ensure it fires - console.log("[Broadcast] startBroadcast ENTRY:", { - hasMainStream: !!mainStream, - hasSelfStream: !!selfStream, - isConnecting, - isLive - }); - - // Remote log (async but fire-and-forget) - await remoteLog("log", "[Broadcast] startBroadcast called:", { - hasMainStream: !!mainStream, - hasSelfStream: !!selfStream, - metadata, - isConnecting, - isLive - }); - - if (isConnecting || isLive) { - remoteLog("warn", "[Broadcast] startBroadcast early return:", { isConnecting, isLive }); - return; - } - setIsConnecting(true); - setError(null); - - try { - // Create stream on server first - remoteLog("log", "[Broadcast] Creating stream on server:", STREAM_SERVER); - const createRes = await fetch(`${STREAM_SERVER}/api/streams`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - id: streamID, - type: "webrtc", - title: metadata.title || "Live Stream", - lat: metadata.lat, - lon: metadata.lon, - broadcaster_id: userID, - broadcaster_name: username, - }), - }); - - if (!createRes.ok) { - const errData = await createRes.json().catch(() => ({})); - remoteLog("error", "[Broadcast] Failed to create stream:", errData); - throw new Error(errData.error || "Failed to create stream"); - } - - remoteLog("log", "[Broadcast] Stream created on server, setting up WebRTC..."); - - // Create peer connection - const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); - - // Add main stream tracks (video + audio) - if (mainStream) { - mainStream.getTracks().forEach((track) => { - pc.addTrack(track, mainStream); - }); - } - - // Add self/presenter stream (video only, labeled) - if (selfStream) { - selfStream.getVideoTracks().forEach((track) => { - const sender = pc.addTrack(track, selfStream); - // The server will identify this as the presenter track by stream ID - }); - } - - // Handle ICE candidates - pc.onicecandidate = (event) => { - if (event.candidate && wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ - type: "ice_candidate", - candidate: event.candidate, - }) - ); - } - }; - - pc.onconnectionstatechange = () => { - console.log("[Broadcast] Connection state:", pc.connectionState); - if (pc.connectionState === "failed" || pc.connectionState === "disconnected") { - setError("Connection lost"); - setIsLive(false); - } - }; - - // Create and send offer - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - const res = await fetch( - `${STREAM_SERVER}/api/webrtc/broadcast/${streamID}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - sdp: offer.sdp, - userID, - username, - }), - } - ); - - if (!res.ok) { - const errData = await res.json().catch(() => ({})); - remoteLog("error", "[Broadcast] WebRTC broadcast failed:", errData); - throw new Error(errData.error || "Failed to start broadcast"); - } - - const { sdp: answerSDP } = await res.json(); - await pc.setRemoteDescription({ type: "answer", sdp: answerSDP }); - - pcRef.current = pc; - setIsLive(true); - - // Connect event socket for real-time updates - connectEventSocket(); - - remoteLog("log", "[Broadcast] Started successfully, isLive=true"); - } catch (err) { - remoteLog("error", "[Broadcast] Failed to start:", err.message); - setError(err.message || "Failed to start broadcast"); - } finally { - setIsConnecting(false); - } - }, - [streamID, userID, username, token, isConnecting, isLive, connectEventSocket] - ); - - // Store stream metadata for post creation - const streamMetaRef = useRef(null); - - const setStreamMeta = useCallback((meta) => { - streamMetaRef.current = meta; - }, []); - - // Stop broadcasting - const stopBroadcast = useCallback(async (saveAsPost = false) => { - remoteLog("log", "[Broadcast] stopBroadcast called:", { saveAsPost, hasMeta: !!streamMetaRef.current }); - // Close peer connection - if (pcRef.current) { - pcRef.current.close(); - pcRef.current = null; - } - - // Close participant connections - participantPCsRef.current.forEach((pc) => pc.close()); - participantPCsRef.current.clear(); - - // Close WebSocket - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - - // Stop recording and get video URL - let videoURL = ""; - try { - const stopRes = await fetch(`${STREAM_SERVER}/api/webrtc/stop/${streamID}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); - if (stopRes.ok) { - const data = await stopRes.json(); - videoURL = data.video_url || ""; - remoteLog("log", "[Broadcast] Recording stopped, video URL:", videoURL); - } - } catch (err) { - console.error("[Broadcast] Failed to stop recording:", err); - } - - // If saveAsPost, create a post on the main backend - if (saveAsPost && streamMetaRef.current) { - const meta = streamMetaRef.current; - try { - remoteLog("log", "[Broadcast] Creating post with meta:", meta, "API_BASE:", API_BASE, "videoURL:", videoURL); - const postData = { - title: meta.title || "Live stream", - snippet: `Live stream by ${username}`, - category: meta.category || "NEWS", - sub_category: meta.sub_category || "Local", - lat: meta.lat, - lon: meta.lon, - author: username, - source: "sociowire", - visibility: "public", - video_url: videoURL || undefined, // Include video URL if available - }; - remoteLog("log", "[Broadcast] POST data:", postData); - const res = await fetch(`${API_BASE}/api/post`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify(postData), - }); - if (res.ok) { - const post = await res.json(); - remoteLog("log", "[Broadcast] Created post from live:", post?.id || post); - // Dispatch event so MapView can add the post to the map immediately - window.dispatchEvent(new CustomEvent('live-post-created', { detail: post })); - } else { - const errText = await res.text(); - remoteLog("error", "[Broadcast] Failed to create post:", res.status, errText); - } - } catch (err) { - remoteLog("error", "[Broadcast] Error creating post:", err.message); - } - } - - setIsLive(false); - setViewerCount(0); - setParticipants([]); - setJoinRequests([]); - setError(null); - streamMetaRef.current = null; - - console.log("[Broadcast] Stopped"); - }, [streamID, token, username]); - - // Approve join request - const approveJoinRequest = useCallback( - async (requestUserID) => { - try { - const res = await fetch( - `${STREAM_SERVER}/api/streams/${streamID}/approve/${requestUserID}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - } - ); - - if (!res.ok) { - throw new Error("Failed to approve join request"); - } - - // Will receive participant_joined event via WebSocket - } catch (err) { - console.error("[Broadcast] Failed to approve join:", err); - setError(err.message); - } - }, - [streamID, token] - ); - - // Deny join request - const denyJoinRequest = useCallback( - async (requestUserID) => { - try { - await fetch( - `${STREAM_SERVER}/api/streams/${streamID}/deny/${requestUserID}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - } - ); - - setJoinRequests((prev) => prev.filter((r) => r.userID !== requestUserID)); - } catch (err) { - console.error("[Broadcast] Failed to deny join:", err); - } - }, - [streamID, token] - ); - - // Kick participant - const kickParticipant = useCallback( - async (participantID) => { - try { - await fetch( - `${STREAM_SERVER}/api/streams/${streamID}/kick/${participantID}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - } - ); - - handleParticipantLeft(participantID); - } catch (err) { - console.error("[Broadcast] Failed to kick participant:", err); - } - }, - [streamID, token, handleParticipantLeft] - ); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (isLive) { - stopBroadcast(false); - } - }; - }, []); - - return { - isLive, - isConnecting, - error, - viewerCount, - participants, - joinRequests, - startBroadcast, - stopBroadcast, - setStreamMeta, - approveJoinRequest, - denyJoinRequest, - kickParticipant, - }; -} - -export default useWebRTCBroadcast; diff --git a/src/components/Live/useWebRTCViewer.js b/src/components/Live/useWebRTCViewer.js deleted file mode 100644 index c645fec..0000000 --- a/src/components/Live/useWebRTCViewer.js +++ /dev/null @@ -1,426 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from "react"; - -const STREAM_SERVER = "https://stream.sociowire.com"; -const ICE_SERVERS = [ - { urls: "stun:stun.l.google.com:19302" }, - { urls: "stun:stun1.l.google.com:19302" }, -]; - -/** - * Hook for WebRTC viewing with join-as-participant capability - */ -export function useWebRTCViewer({ streamID, userID, username, token }) { - const [isConnected, setIsConnected] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [error, setError] = useState(null); - - // Video streams received - const [mainStream, setMainStream] = useState(null); // Host's main camera - const [presenterStream, setPresenterStream] = useState(null); // Host's self-view - const [participantStreams, setParticipantStreams] = useState([]); // Other participants - - // Stream metadata - const [streamInfo, setStreamInfo] = useState(null); - const [viewerCount, setViewerCount] = useState(0); - - // Join request state - const [joinRequestStatus, setJoinRequestStatus] = useState(null); // null, 'pending', 'approved', 'denied' - const [isParticipant, setIsParticipant] = useState(false); - - const pcRef = useRef(null); - const wsRef = useRef(null); - const localStreamRef = useRef(null); // Our camera when we join as participant - - // Connect to event WebSocket - const connectEventSocket = useCallback(() => { - if (!streamID) return; - - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const wsUrl = `${protocol}://stream.sociowire.com/api/streams/${streamID}/events`; - - try { - const ws = new WebSocket(wsUrl); - - ws.onopen = () => { - console.log("[Viewer] Event socket connected"); - ws.send(JSON.stringify({ type: "auth", token, userID, role: "viewer" })); - }; - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data); - handleEventMessage(msg); - } catch (err) { - console.error("[Viewer] Failed to parse event:", err); - } - }; - - ws.onclose = () => { - console.log("[Viewer] Event socket closed"); - }; - - ws.onerror = (err) => { - console.error("[Viewer] Event socket error:", err); - }; - - wsRef.current = ws; - } catch (err) { - console.error("[Viewer] Failed to connect event socket:", err); - } - }, [streamID, token, userID]); - - const handleEventMessage = useCallback((msg) => { - switch (msg.type) { - case "stream_info": - setStreamInfo(msg.stream); - break; - - case "viewer_count": - setViewerCount(msg.count || 0); - break; - - case "join_approved": - setJoinRequestStatus("approved"); - // Now we can send our video - startParticipating(); - break; - - case "join_denied": - setJoinRequestStatus("denied"); - break; - - case "participant_joined": - handleParticipantJoined(msg); - break; - - case "participant_left": - handleParticipantLeft(msg.userID); - break; - - case "host_answer": - // Host sent us an answer for our participant offer - handleHostAnswer(msg); - break; - - case "host_ice": - handleHostICE(msg); - break; - - case "stream_ended": - setError("Stream has ended"); - disconnect(); - break; - - case "kicked": - setError("You have been removed from the stream"); - setIsParticipant(false); - stopLocalStream(); - break; - - default: - console.log("[Viewer] Unknown event:", msg.type); - } - }, []); - - const handleParticipantJoined = useCallback((msg) => { - const { userID: participantID, username: participantName } = msg; - - setParticipantStreams((prev) => [ - ...prev.filter((p) => p.userID !== participantID), - { userID: participantID, username: participantName, stream: null }, - ]); - }, []); - - const handleParticipantLeft = useCallback((participantID) => { - setParticipantStreams((prev) => prev.filter((p) => p.userID !== participantID)); - }, []); - - const handleHostAnswer = useCallback(async (msg) => { - const { sdp } = msg; - if (pcRef.current && sdp) { - try { - await pcRef.current.setRemoteDescription({ type: "answer", sdp }); - setIsParticipant(true); - console.log("[Viewer] Now participating with video"); - } catch (err) { - console.error("[Viewer] Failed to set host answer:", err); - } - } - }, []); - - const handleHostICE = useCallback((msg) => { - const { candidate } = msg; - if (pcRef.current && candidate) { - pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)).catch((err) => { - console.error("[Viewer] Failed to add host ICE candidate:", err); - }); - } - }, []); - - // Connect as viewer (receive only) - const connect = useCallback(async () => { - if (isConnecting || isConnected) return; - setIsConnecting(true); - setError(null); - - try { - // Get stream info first - const infoRes = await fetch(`${STREAM_SERVER}/api/streams/${streamID}`); - if (!infoRes.ok) { - throw new Error("Stream not found or has ended"); - } - const info = await infoRes.json(); - setStreamInfo(info); - - // Create peer connection - const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); - - // We need to add transceivers to receive video/audio - pc.addTransceiver("video", { direction: "recvonly" }); - pc.addTransceiver("video", { direction: "recvonly" }); // For presenter track - pc.addTransceiver("audio", { direction: "recvonly" }); - - pc.ontrack = (event) => { - const track = event.track; - const stream = event.streams[0]; - - console.log("[Viewer] Received track:", track.kind, stream?.id); - - // Determine which stream this is based on stream ID or order - // First video stream is main, second is presenter - if (track.kind === "video") { - setMainStream((prev) => { - if (!prev) return stream; - setPresenterStream(stream); - return prev; - }); - } - }; - - pc.onicecandidate = (event) => { - if (event.candidate && wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ - type: "ice_candidate", - candidate: event.candidate, - }) - ); - } - }; - - pc.onconnectionstatechange = () => { - console.log("[Viewer] Connection state:", pc.connectionState); - if (pc.connectionState === "failed" || pc.connectionState === "disconnected") { - setError("Connection lost"); - setIsConnected(false); - } - }; - - // Create offer - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - // Send to server - const res = await fetch(`${STREAM_SERVER}/api/webrtc/watch/${streamID}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - sdp: offer.sdp, - userID, - username, - }), - }); - - if (!res.ok) { - const errData = await res.json().catch(() => ({})); - throw new Error(errData.error || "Failed to connect to stream"); - } - - const { sdp: answerSDP } = await res.json(); - await pc.setRemoteDescription({ type: "answer", sdp: answerSDP }); - - pcRef.current = pc; - setIsConnected(true); - - // Connect event socket - connectEventSocket(); - - console.log("[Viewer] Connected successfully"); - } catch (err) { - console.error("[Viewer] Failed to connect:", err); - setError(err.message || "Failed to connect"); - } finally { - setIsConnecting(false); - } - }, [streamID, userID, username, token, isConnecting, isConnected, connectEventSocket]); - - // Disconnect - const disconnect = useCallback(() => { - if (pcRef.current) { - pcRef.current.close(); - pcRef.current = null; - } - - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - - stopLocalStream(); - - setIsConnected(false); - setMainStream(null); - setPresenterStream(null); - setParticipantStreams([]); - setIsParticipant(false); - setJoinRequestStatus(null); - - console.log("[Viewer] Disconnected"); - }, []); - - // Request to join as participant - const requestToJoin = useCallback(async () => { - if (joinRequestStatus === "pending") return; - - try { - const res = await fetch( - `${STREAM_SERVER}/api/streams/${streamID}/join-request`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ userID, username }), - } - ); - - if (!res.ok) { - throw new Error("Failed to send join request"); - } - - setJoinRequestStatus("pending"); - } catch (err) { - console.error("[Viewer] Failed to request join:", err); - setError(err.message); - } - }, [streamID, userID, username, token, joinRequestStatus]); - - // Cancel join request - const cancelJoinRequest = useCallback(async () => { - try { - await fetch( - `${STREAM_SERVER}/api/streams/${streamID}/join-request/${userID}`, - { - method: "DELETE", - headers: { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - } - ); - setJoinRequestStatus(null); - } catch (err) { - console.error("[Viewer] Failed to cancel join request:", err); - } - }, [streamID, userID, token]); - - // Start participating (send our video after approval) - const startParticipating = useCallback(async () => { - try { - // Get our camera - const localStream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: "user", width: 640, height: 480 }, - audio: true, - }); - localStreamRef.current = localStream; - - // Create new peer connection for sending our stream - // (or reuse existing one with renegotiation) - if (pcRef.current) { - // Add our tracks - localStream.getTracks().forEach((track) => { - pcRef.current.addTrack(track, localStream); - }); - - // Renegotiate - const offer = await pcRef.current.createOffer(); - await pcRef.current.setLocalDescription(offer); - - // Send offer to host via WebSocket - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ - type: "participant_offer", - sdp: offer.sdp, - }) - ); - } - } - } catch (err) { - console.error("[Viewer] Failed to start participating:", err); - setError("Failed to access camera"); - } - }, []); - - // Stop local stream - const stopLocalStream = useCallback(() => { - if (localStreamRef.current) { - localStreamRef.current.getTracks().forEach((track) => track.stop()); - localStreamRef.current = null; - } - }, []); - - // Leave as participant (become viewer again) - const leaveAsParticipant = useCallback(async () => { - stopLocalStream(); - setIsParticipant(false); - - // Notify server - try { - await fetch( - `${STREAM_SERVER}/api/streams/${streamID}/leave`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ userID }), - } - ); - } catch (err) { - console.error("[Viewer] Failed to notify leave:", err); - } - }, [streamID, userID, token, stopLocalStream]); - - // Cleanup on unmount - useEffect(() => { - return () => { - disconnect(); - }; - }, []); - - return { - isConnected, - isConnecting, - error, - mainStream, - presenterStream, - participantStreams, - streamInfo, - viewerCount, - joinRequestStatus, - isParticipant, - localStream: localStreamRef.current, - connect, - disconnect, - requestToJoin, - cancelJoinRequest, - leaveAsParticipant, - }; -} - -export default useWebRTCViewer;