Fix LiveKit live streaming and remove old WebRTC code
- Add double-click guard in handleGoLive to prevent duplicate posts - Update LiveKit URLs to use stream.sociowire.com - Remove deprecated WebRTC broadcast/viewer components - Bump service worker cache to v11 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a9ac9051c5
commit
197151d818
|
|
@ -1,5 +1,5 @@
|
||||||
// public/service-worker.js
|
// public/service-worker.js
|
||||||
const CACHE_NAME = 'sociowire-v9';
|
const CACHE_NAME = 'sociowire-v11';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/icons/icon-192x192.png',
|
'/icons/icon-192x192.png',
|
||||||
'/icons/icon-512x512.png',
|
'/icons/icon-512x512.png',
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
|
||||||
<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>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-broadcast-close"
|
|
||||||
onClick={() => {
|
|
||||||
if (isLive) {
|
|
||||||
handleEndStream();
|
|
||||||
} else {
|
|
||||||
stopCameras();
|
|
||||||
onClose?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="sw-broadcast-content">
|
|
||||||
{/* Video preview area */}
|
|
||||||
<div className="sw-broadcast-video-area">
|
|
||||||
{/* Main video (fullscreen) */}
|
|
||||||
<div className="sw-broadcast-main-video">
|
|
||||||
{mainStream ? (
|
|
||||||
<>
|
|
||||||
<video
|
|
||||||
ref={mainVideoRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
className="sw-broadcast-video"
|
|
||||||
/>
|
|
||||||
{/* Camera label for debug */}
|
|
||||||
<div className="sw-broadcast-camera-label">
|
|
||||||
<i className="fa-solid fa-video" />
|
|
||||||
<span>{isSwapped ? "Back" : "Front"} cam</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="sw-broadcast-video-placeholder">
|
|
||||||
{cameraStarting ? (
|
|
||||||
<>
|
|
||||||
<i className="fa-solid fa-spinner fa-spin" />
|
|
||||||
<span>Starting camera...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<i className="fa-solid fa-video-slash" />
|
|
||||||
<span>Camera not available</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Self PiP (draggable corner) - show if we have dual cameras */}
|
|
||||||
{selfStream && (
|
|
||||||
<div className="sw-broadcast-pip">
|
|
||||||
<video
|
|
||||||
ref={selfVideoRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
className="sw-broadcast-pip-video"
|
|
||||||
/>
|
|
||||||
<div className="sw-broadcast-pip-label">{isSwapped ? "Front" : "Back"}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No dual camera indicator */}
|
|
||||||
{!selfStream && mainStream && !hasDualCameras && (
|
|
||||||
<div className="sw-broadcast-no-pip">
|
|
||||||
<i className="fa-solid fa-camera" />
|
|
||||||
<span>Single camera</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Participant strip (when live with participants) */}
|
|
||||||
{isLive && participants.length > 0 && (
|
|
||||||
<div className="sw-broadcast-participants-strip">
|
|
||||||
{participants.map((p) => (
|
|
||||||
<div key={p.userID} className="sw-broadcast-participant">
|
|
||||||
<video
|
|
||||||
ref={(el) => {
|
|
||||||
participantRefs.current[p.userID] = el;
|
|
||||||
}}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
className="sw-broadcast-participant-video"
|
|
||||||
/>
|
|
||||||
<div className="sw-broadcast-participant-name">{p.username}</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-broadcast-kick-btn"
|
|
||||||
onClick={() => kickParticipant(p.userID)}
|
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Camera controls overlay */}
|
|
||||||
<div className="sw-broadcast-controls-overlay">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`sw-broadcast-control-btn ${!hasDualCameras ? "is-disabled" : ""} ${isSwapping ? "is-swapping" : ""}`}
|
|
||||||
onClick={hasDualCameras && !isSwapping ? swapCameras : undefined}
|
|
||||||
title={hasDualCameras ? (isSwapping ? "Switching..." : "Swap cameras") : "Single camera only"}
|
|
||||||
disabled={!hasDualCameras || isSwapping}
|
|
||||||
>
|
|
||||||
<i className={`fa-solid ${isSwapping ? "fa-spinner fa-spin" : "fa-camera-rotate"}`} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`sw-broadcast-control-btn ${audioMuted ? "is-muted" : ""}`}
|
|
||||||
onClick={handleToggleAudio}
|
|
||||||
title={audioMuted ? "Unmute" : "Mute"}
|
|
||||||
>
|
|
||||||
<i className={`fa-solid ${audioMuted ? "fa-microphone-slash" : "fa-microphone"}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Side panel */}
|
|
||||||
<div className="sw-broadcast-side">
|
|
||||||
{/* Title input (before going live) */}
|
|
||||||
{!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-category-row">
|
|
||||||
<div className="sw-broadcast-field">
|
|
||||||
<label className="sw-broadcast-label">Category</label>
|
|
||||||
<select
|
|
||||||
className="sw-broadcast-select"
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => {
|
|
||||||
const cat = e.target.value;
|
|
||||||
setCategory(cat);
|
|
||||||
setSubCategory((CREATE_CATEGORY_MAP[cat] || CREATE_CATEGORY_MAP.News)[0]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{MAIN_CATEGORIES.map((cat) => (
|
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="sw-broadcast-field">
|
|
||||||
<label className="sw-broadcast-label">Sub-category</label>
|
|
||||||
<select
|
|
||||||
className="sw-broadcast-select"
|
|
||||||
value={subCategory}
|
|
||||||
onChange={(e) => setSubCategory(e.target.value)}
|
|
||||||
>
|
|
||||||
{(CREATE_CATEGORY_MAP[category] || CREATE_CATEGORY_MAP.News).map((sub) => (
|
|
||||||
<option key={sub} value={sub}>{sub}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sw-broadcast-hint">
|
|
||||||
Location: {coords ? `${coords[1].toFixed(4)}, ${coords[0].toFixed(4)}` : "Unknown"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Live info */}
|
|
||||||
{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-users" /> {participants.length} participants</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Join requests (when live) */}
|
|
||||||
{isLive && joinRequests.length > 0 && (
|
|
||||||
<div className="sw-broadcast-requests">
|
|
||||||
<h4>Join Requests</h4>
|
|
||||||
{joinRequests.map((req) => (
|
|
||||||
<div key={req.userID} className="sw-broadcast-request">
|
|
||||||
<span className="sw-broadcast-request-name">{req.username}</span>
|
|
||||||
<div className="sw-broadcast-request-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-broadcast-request-btn approve"
|
|
||||||
onClick={() => approveJoinRequest(req.userID)}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-check" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-broadcast-request-btn deny"
|
|
||||||
onClick={() => denyJoinRequest(req.userID)}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error display */}
|
|
||||||
{error && (
|
|
||||||
<div className="sw-broadcast-error">
|
|
||||||
<i className="fa-solid fa-exclamation-triangle" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="sw-broadcast-actions">
|
|
||||||
{!isLive ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-broadcast-btn primary"
|
|
||||||
onClick={handleGoLive}
|
|
||||||
disabled={isConnecting || !mainStream}
|
|
||||||
>
|
|
||||||
{isConnecting ? (
|
|
||||||
<>
|
|
||||||
<i className="fa-solid fa-spinner fa-spin" />
|
|
||||||
<span>Connecting...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<i className="fa-solid fa-broadcast-tower" />
|
|
||||||
<span>Go Live</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-broadcast-btn danger"
|
|
||||||
onClick={handleEndStream}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-stop" />
|
|
||||||
<span>End Stream</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* End stream confirmation modal */}
|
|
||||||
{showEndConfirm && (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<label className="sw-broadcast-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={saveAsPost}
|
|
||||||
onChange={(e) => setSaveAsPost(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Save as post on the map</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="sw-broadcast-confirm-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-broadcast-btn secondary"
|
|
||||||
onClick={() => setShowEndConfirm(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-broadcast-btn danger"
|
|
||||||
onClick={confirmEndStream}
|
|
||||||
>
|
|
||||||
End Stream
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { CREATE_CATEGORY_MAP, FILTER_MAIN_CATEGORIES } from "../Map/mapConfig";
|
||||||
import "./Live.css";
|
import "./Live.css";
|
||||||
|
|
||||||
const API_BASE = (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
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
|
* LiveKit-based broadcast component
|
||||||
|
|
@ -205,6 +205,10 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
}, [commentInput, livePostId, refreshComments]);
|
}, [commentInput, livePostId, refreshComments]);
|
||||||
|
|
||||||
const handleGoLive = useCallback(async () => {
|
const handleGoLive = useCallback(async () => {
|
||||||
|
// Prevent double-clicks and duplicate calls
|
||||||
|
if (isConnecting || isLive || livePostId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
alert("Please log in to start a live stream.");
|
alert("Please log in to start a live stream.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -279,6 +283,9 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
username,
|
username,
|
||||||
liveCategory,
|
liveCategory,
|
||||||
liveSubCategory,
|
liveSubCategory,
|
||||||
|
isConnecting,
|
||||||
|
isLive,
|
||||||
|
livePostId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleEndStream = useCallback(async (save) => {
|
const handleEndStream = useCallback(async (save) => {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { Room, RoomEvent } from "livekit-client";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import "./Live.css";
|
import "./Live.css";
|
||||||
|
|
||||||
const LIVEKIT_URL = "wss://live.us4.sociowire.com";
|
const LIVEKIT_URL = "wss://stream.sociowire.com";
|
||||||
const LIVEKIT_API = "https://stream.us4.sociowire.com";
|
const LIVEKIT_API = "https://stream.sociowire.com";
|
||||||
|
|
||||||
function roomNameFromPost(post) {
|
function roomNameFromPost(post) {
|
||||||
const raw = (post?.embed_url || post?.video_url || "").toString().trim();
|
const raw = (post?.embed_url || post?.video_url || "").toString().trim();
|
||||||
|
|
|
||||||
|
|
@ -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 = `<i class="fa-solid fa-broadcast-tower"></i>`;
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="sw-viewer-modal" onClick={handleBackdropClick}>
|
|
||||||
<div className="sw-viewer-container">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="sw-viewer-header">
|
|
||||||
<div className="sw-viewer-header-left">
|
|
||||||
<div className="sw-viewer-live-badge">
|
|
||||||
<span className="sw-viewer-dot" />
|
|
||||||
<span>LIVE</span>
|
|
||||||
</div>
|
|
||||||
<span className="sw-viewer-title">{info.title || "Live Stream"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="sw-viewer-header-right">
|
|
||||||
<span className="sw-viewer-count">
|
|
||||||
<i className="fa-solid fa-eye" /> {viewerCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-viewer-close"
|
|
||||||
onClick={handleClose}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="sw-viewer-content">
|
|
||||||
{/* Video area */}
|
|
||||||
<div className="sw-viewer-video-area">
|
|
||||||
{/* Main video */}
|
|
||||||
<div className="sw-viewer-main-video">
|
|
||||||
{isConnecting ? (
|
|
||||||
<div className="sw-viewer-video-placeholder">
|
|
||||||
<i className="fa-solid fa-spinner fa-spin" />
|
|
||||||
<span>Connecting...</span>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="sw-viewer-video-placeholder error">
|
|
||||||
<i className="fa-solid fa-exclamation-triangle" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
) : mainStream ? (
|
|
||||||
<video
|
|
||||||
ref={mainVideoRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
className="sw-viewer-video"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="sw-viewer-video-placeholder">
|
|
||||||
<i className="fa-solid fa-video" />
|
|
||||||
<span>Waiting for stream...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Presenter PiP */}
|
|
||||||
{presenterStream && (
|
|
||||||
<div className="sw-viewer-pip">
|
|
||||||
<video
|
|
||||||
ref={presenterVideoRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
className="sw-viewer-pip-video"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Participant strip */}
|
|
||||||
{participantStreams.length > 0 && (
|
|
||||||
<div className="sw-viewer-participants-strip">
|
|
||||||
{participantStreams.map((p) => (
|
|
||||||
<div key={p.userID} className="sw-viewer-participant">
|
|
||||||
<video
|
|
||||||
ref={(el) => {
|
|
||||||
participantRefs.current[p.userID] = el;
|
|
||||||
}}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
className="sw-viewer-participant-video"
|
|
||||||
/>
|
|
||||||
<div className="sw-viewer-participant-name">{p.username}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Self view when participating */}
|
|
||||||
{isParticipant && localStream && (
|
|
||||||
<div className="sw-viewer-self-pip">
|
|
||||||
<video
|
|
||||||
ref={localVideoRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
className="sw-viewer-pip-video"
|
|
||||||
/>
|
|
||||||
<div className="sw-viewer-pip-label">You</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Side panel */}
|
|
||||||
<div className="sw-viewer-side">
|
|
||||||
{/* Stream info */}
|
|
||||||
<div className="sw-viewer-info">
|
|
||||||
<div className="sw-viewer-host">
|
|
||||||
<i className="fa-solid fa-user" />
|
|
||||||
<span>{info.broadcaster_name || "Host"}</span>
|
|
||||||
</div>
|
|
||||||
{info.title && <p className="sw-viewer-description">{info.title}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mini map */}
|
|
||||||
<div ref={mapContainerRef} className="sw-viewer-map" />
|
|
||||||
|
|
||||||
{/* Join section */}
|
|
||||||
{authenticated && !isParticipant && (
|
|
||||||
<div className="sw-viewer-join-section">
|
|
||||||
{joinRequestStatus === null && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-viewer-btn primary"
|
|
||||||
onClick={requestToJoin}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-hand" />
|
|
||||||
<span>Request to Join</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{joinRequestStatus === "pending" && (
|
|
||||||
<div className="sw-viewer-join-pending">
|
|
||||||
<i className="fa-solid fa-clock" />
|
|
||||||
<span>Waiting for host approval...</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-viewer-btn-small"
|
|
||||||
onClick={cancelJoinRequest}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{joinRequestStatus === "denied" && (
|
|
||||||
<div className="sw-viewer-join-denied">
|
|
||||||
<i className="fa-solid fa-times-circle" />
|
|
||||||
<span>Join request denied</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Leave participant button */}
|
|
||||||
{isParticipant && (
|
|
||||||
<div className="sw-viewer-participant-controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-viewer-btn danger"
|
|
||||||
onClick={leaveAsParticipant}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-sign-out-alt" />
|
|
||||||
<span>Leave as Participant</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="sw-viewer-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-viewer-action-btn"
|
|
||||||
onClick={handleShare}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-share-nodes" />
|
|
||||||
<span>Share</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
export { default as CameraMiniMap } from "./CameraMiniMap";
|
export { default as CameraMiniMap } from "./CameraMiniMap";
|
||||||
export { default as CameraCard } from "./CameraCard";
|
export { default as CameraCard } from "./CameraCard";
|
||||||
export { default as VideoModal } from "./VideoModal";
|
export { default as VideoModal } from "./VideoModal";
|
||||||
export { default as LiveBroadcastModal } from "./LiveBroadcastModal";
|
|
||||||
export { default as LiveKitBroadcast } from "./LiveKitBroadcast";
|
export { default as LiveKitBroadcast } from "./LiveKitBroadcast";
|
||||||
export { default as LiveKitViewerModal } from "./LiveKitViewerModal";
|
export { default as LiveKitViewerModal } from "./LiveKitViewerModal";
|
||||||
export { default as LiveViewerModal } from "./LiveViewerModal";
|
|
||||||
export { useDualCamera } from "./useDualCamera";
|
export { useDualCamera } from "./useDualCamera";
|
||||||
export { useWebRTCBroadcast } from "./useWebRTCBroadcast";
|
|
||||||
export { useWebRTCViewer } from "./useWebRTCViewer";
|
|
||||||
export { useLiveKit } from "./useLiveKit";
|
export { useLiveKit } from "./useLiveKit";
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
|
|
||||||
const LIVEKIT_URL = "wss://stream.sociowire.com";
|
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
|
* Hook for LiveKit-based live streaming
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
Loading…
Reference in New Issue