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 ? (
- <>
-
- {/* Camera label for debug */}
-
-
- {isSwapped ? "Back" : "Front"} cam
-
- >
- ) : (
-
- {cameraStarting ? (
- <>
-
- Starting camera...
- >
- ) : (
- <>
-
- Camera not available
- >
- )}
-
- )}
-
- {/* Self PiP (draggable corner) - show if we have dual cameras */}
- {selfStream && (
-
-
-
{isSwapped ? "Front" : "Back"}
-
- )}
-
- {/* No dual camera indicator */}
- {!selfStream && mainStream && !hasDualCameras && (
-
-
- Single camera
-
- )}
-
- {/* Participant strip (when live with participants) */}
- {isLive && participants.length > 0 && (
-
- {participants.map((p) => (
-
-
- ))}
-
- )}
-
- {/* Camera controls overlay */}
-
-
-
-
-
-
-
- {/* 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 ? (
-
- ) : (
-
-
- Waiting for stream...
-
- )}
-
- {/* Presenter PiP */}
- {presenterStream && (
-
-
-
- )}
-
- {/* Participant strip */}
- {participantStreams.length > 0 && (
-
- {participantStreams.map((p) => (
-
-
- ))}
-
- )}
-
- {/* Self view when participating */}
- {isParticipant && localStream && (
-
- )}
-
-
-
- {/* 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;