Update live features
This commit is contained in:
parent
eedebaea46
commit
15141835f2
|
|
@ -4,7 +4,7 @@ COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_API_BASE_URL=
|
ARG VITE_API_BASE_URL=
|
||||||
ARG VITE_KC_URL=/auth
|
ARG VITE_KC_URL=https://auth.sociowire.com
|
||||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||||
ENV VITE_KC_URL=${VITE_KC_URL}
|
ENV VITE_KC_URL=${VITE_KC_URL}
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
|
"livekit-client": "^2.0.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"keycloak-js": "^26.2.1",
|
"keycloak-js": "^26.2.1",
|
||||||
"maplibre-gl": "^5.14.0",
|
"maplibre-gl": "^5.14.0",
|
||||||
|
|
@ -27,9 +28,9 @@
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"vite": "npm:rolldown-vite@7.2.5"
|
"vite": "^5.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "npm:rolldown-vite@7.2.5"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -722,7 +722,6 @@ export function AuthProvider({ children }) {
|
||||||
lastError: error || "",
|
lastError: error || "",
|
||||||
lastInfo: lastInfo || "",
|
lastInfo: lastInfo || "",
|
||||||
needsEmailVerification: !!needsEmailVerification,
|
needsEmailVerification: !!needsEmailVerification,
|
||||||
needsUsername: !!needsUsername,
|
|
||||||
resendVerificationEmail,
|
resendVerificationEmail,
|
||||||
loginWithProvider,
|
loginWithProvider,
|
||||||
ssoEnabled: keycloakEnabled(),
|
ssoEnabled: keycloakEnabled(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import "./Live.css";
|
||||||
|
|
||||||
|
function formatRegionLabel(region) {
|
||||||
|
if (!region) return "Unknown";
|
||||||
|
return region.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CameraCard({ camera, selected, onSelect }) {
|
||||||
|
const [thumbError, setThumbError] = useState(false);
|
||||||
|
|
||||||
|
if (!camera) return null;
|
||||||
|
|
||||||
|
const name = camera.name_fr || camera.name_en || "Camera";
|
||||||
|
const region = formatRegionLabel(camera.region);
|
||||||
|
const route = camera.route || "";
|
||||||
|
const source = camera.source || "quebec511";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={"post-card sw-wall-card sw-camera-card" + (selected ? " selected" : "")}
|
||||||
|
onClick={() => onSelect && onSelect(camera)}
|
||||||
|
>
|
||||||
|
<div className="sw-wall-head">
|
||||||
|
<div className="sw-wall-avatar sw-camera-avatar">
|
||||||
|
<i className="fa-solid fa-video" />
|
||||||
|
</div>
|
||||||
|
<div className="sw-wall-meta">
|
||||||
|
<div className="sw-wall-author">{name}</div>
|
||||||
|
<div className="sw-wall-time">{region}</div>
|
||||||
|
</div>
|
||||||
|
<div className="sw-wall-badge sw-camera-badge">
|
||||||
|
<i className="fa-solid fa-broadcast-tower" /> LIVE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sw-wall-image sw-camera-thumbnail">
|
||||||
|
{camera.thumbnail_url && !thumbError ? (
|
||||||
|
<img
|
||||||
|
src={camera.thumbnail_url}
|
||||||
|
alt={name}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setThumbError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="sw-camera-thumb-fallback">
|
||||||
|
<i className="fa-solid fa-video" />
|
||||||
|
<span>LIVE FEED</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sw-camera-live-indicator">
|
||||||
|
<span className="sw-camera-dot" />
|
||||||
|
<span>LIVE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sw-wall-title">{name}</div>
|
||||||
|
{route && <div className="sw-wall-meta-line">Route {route}</div>}
|
||||||
|
|
||||||
|
<div className="sw-wall-stats">
|
||||||
|
<span>
|
||||||
|
<i className="fa-solid fa-map-marker-alt" /> {region}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="fa-solid fa-signal" /> {source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sw-wall-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-wall-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onSelect) onSelect(camera);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-play" /> Watch
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-wall-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const shareUrl = camera.embed_url || camera.video_url || "";
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({ title: name, url: shareUrl }).catch(() => {});
|
||||||
|
} else if (navigator.clipboard && shareUrl) {
|
||||||
|
navigator.clipboard.writeText(shareUrl);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-share-nodes" /> Share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
import "./Live.css";
|
||||||
|
|
||||||
|
const API_BASE = "/live/api";
|
||||||
|
const MAPTILER_KEY = "DvwhzlPmC55ZK28nLtXL";
|
||||||
|
|
||||||
|
function formatRegionLabel(region) {
|
||||||
|
if (!region) return "Unknown";
|
||||||
|
return region
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CameraMiniMap({
|
||||||
|
region = null,
|
||||||
|
limit = 50,
|
||||||
|
height = 280,
|
||||||
|
onCameraClick,
|
||||||
|
selectedCameraId = null,
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const markersRef = useRef([]);
|
||||||
|
const [cameras, setCameras] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Fetch cameras
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = region
|
||||||
|
? `${API_BASE}/cameras?region=${encodeURIComponent(region)}`
|
||||||
|
: `${API_BASE}/cameras`;
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch cameras");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const cams = Array.isArray(data.cameras) ? data.cameras : [];
|
||||||
|
setCameras(cams.slice(0, limit));
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error("Camera fetch error:", err);
|
||||||
|
setError(err.message || "Error loading cameras");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [region, limit]);
|
||||||
|
|
||||||
|
// Initialize map
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || mapRef.current) return;
|
||||||
|
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style: `https://api.maptiler.com/maps/streets-v2-dark/style.json?key=${MAPTILER_KEY}`,
|
||||||
|
center: [-71.2, 46.8], // Quebec City default
|
||||||
|
zoom: 6,
|
||||||
|
attributionControl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add camera markers
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !cameras.length) return;
|
||||||
|
|
||||||
|
// Clear old markers
|
||||||
|
markersRef.current.forEach((m) => m.remove());
|
||||||
|
markersRef.current = [];
|
||||||
|
|
||||||
|
// Calculate bounds
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
|
||||||
|
cameras.forEach((cam) => {
|
||||||
|
const lng = Number(cam.lng);
|
||||||
|
const lat = Number(cam.lat);
|
||||||
|
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return;
|
||||||
|
|
||||||
|
bounds.extend([lng, lat]);
|
||||||
|
|
||||||
|
// Create marker element
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "sw-camera-marker";
|
||||||
|
if (selectedCameraId && cam.id === selectedCameraId) {
|
||||||
|
el.classList.add("is-selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera icon
|
||||||
|
el.innerHTML = `<i class="fa-solid fa-video"></i>`;
|
||||||
|
|
||||||
|
const marker = new maplibregl.Marker({ element: el })
|
||||||
|
.setLngLat([lng, lat])
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
el.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onCameraClick) onCameraClick(cam);
|
||||||
|
});
|
||||||
|
|
||||||
|
markersRef.current.push(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit bounds if we have cameras
|
||||||
|
if (cameras.length > 0 && !bounds.isEmpty()) {
|
||||||
|
const tryFit = () => {
|
||||||
|
if (!map || !map.loaded()) return false;
|
||||||
|
try {
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: 40,
|
||||||
|
maxZoom: 10,
|
||||||
|
duration: 500,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tryFit()) {
|
||||||
|
map.once("load", tryFit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
markersRef.current.forEach((m) => m.remove());
|
||||||
|
markersRef.current = [];
|
||||||
|
};
|
||||||
|
}, [cameras, selectedCameraId, onCameraClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sw-camera-minimap" style={{ height }}>
|
||||||
|
<div className="sw-camera-minimap-header">
|
||||||
|
<i className="fa-solid fa-video" />
|
||||||
|
<span>Live Cameras</span>
|
||||||
|
{region && <span className="sw-camera-region-badge">{formatRegionLabel(region)}</span>}
|
||||||
|
<span className="sw-camera-count">{cameras.length}</span>
|
||||||
|
</div>
|
||||||
|
<div ref={containerRef} className="sw-camera-minimap-container" />
|
||||||
|
{loading && (
|
||||||
|
<div className="sw-camera-minimap-overlay">
|
||||||
|
<div className="sw-camera-loader">
|
||||||
|
<i className="fa-solid fa-spinner fa-spin" />
|
||||||
|
<span>Loading cameras...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="sw-camera-minimap-overlay sw-camera-error">
|
||||||
|
<i className="fa-solid fa-exclamation-triangle" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,481 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,334 @@
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { useLiveKit } from "./useLiveKit";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import "./Live.css";
|
||||||
|
|
||||||
|
const API_BASE = (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LiveKit-based broadcast component
|
||||||
|
* Professional video streaming that works everywhere (5G, 4G, WiFi, firewalls)
|
||||||
|
*/
|
||||||
|
export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
|
const { username, token: authToken } = useAuth();
|
||||||
|
const userID = username || `anon_${Date.now()}`;
|
||||||
|
|
||||||
|
const [roomName] = useState(() => `live_${userID}_${Date.now()}`);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [localStream, setLocalStream] = useState(null);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [livePostId, setLivePostId] = useState(null);
|
||||||
|
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLive,
|
||||||
|
isConnecting,
|
||||||
|
error,
|
||||||
|
viewerCount,
|
||||||
|
startBroadcast,
|
||||||
|
stopBroadcast,
|
||||||
|
toggleAudio,
|
||||||
|
} = useLiveKit({
|
||||||
|
roomName,
|
||||||
|
userID,
|
||||||
|
username,
|
||||||
|
token: authToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start camera on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const startCamera = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: "user", width: 1280, height: 720 },
|
||||||
|
audio: true,
|
||||||
|
});
|
||||||
|
setLocalStream(stream);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[LiveKit] Camera error:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
startCamera();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach((t) => t.stop());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update video element when stream changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current && localStream) {
|
||||||
|
videoRef.current.srcObject = localStream;
|
||||||
|
}
|
||||||
|
}, [localStream]);
|
||||||
|
|
||||||
|
const handleGoLive = useCallback(async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
alert("Please enter a title");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!localStream) {
|
||||||
|
alert("Camera not ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get video track from stream
|
||||||
|
const videoTrack = localStream.getVideoTracks()[0];
|
||||||
|
await startBroadcast(videoTrack, true);
|
||||||
|
|
||||||
|
if (onStreamCreated) {
|
||||||
|
onStreamCreated({
|
||||||
|
roomName,
|
||||||
|
title: title.trim(),
|
||||||
|
coords,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/post`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.trim(),
|
||||||
|
snippet: "Live now",
|
||||||
|
category: "EVENT",
|
||||||
|
sub_category: "Live",
|
||||||
|
lat: coords?.lat,
|
||||||
|
lon: coords?.lon,
|
||||||
|
author: username,
|
||||||
|
source: "sociowire",
|
||||||
|
visibility: "public",
|
||||||
|
content_type: "live",
|
||||||
|
embed_url: `livekit://${roomName}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const post = await res.json();
|
||||||
|
setLivePostId(post?.id || null);
|
||||||
|
window.dispatchEvent(new CustomEvent("live-post-created", { detail: post }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[LiveKit] Failed to create live post:", err);
|
||||||
|
}
|
||||||
|
}, [title, localStream, startBroadcast, roomName, coords, onStreamCreated, authToken, username]);
|
||||||
|
|
||||||
|
const handleEndStream = useCallback(async (save) => {
|
||||||
|
await stopBroadcast();
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach((t) => t.stop());
|
||||||
|
}
|
||||||
|
if (livePostId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/live/finalize`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
post_id: livePostId,
|
||||||
|
save: !!save,
|
||||||
|
video_url: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok && save) {
|
||||||
|
const post = await res.json();
|
||||||
|
window.dispatchEvent(new CustomEvent("live-post-updated", { detail: post }));
|
||||||
|
}
|
||||||
|
if (res.ok && !save) {
|
||||||
|
window.dispatchEvent(new CustomEvent("live-post-deleted", { detail: { post_id: livePostId } }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[LiveKit] Failed to finalize live post:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClose?.();
|
||||||
|
}, [stopBroadcast, localStream, onClose, livePostId, authToken, username]);
|
||||||
|
|
||||||
|
const handleBackdropClick = (e) => {
|
||||||
|
if (e.target === e.currentTarget && !isLive) {
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach((t) => t.stop());
|
||||||
|
}
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sw-broadcast-modal" onClick={handleBackdropClick}>
|
||||||
|
<div className="sw-broadcast-container">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sw-broadcast-header">
|
||||||
|
<div className="sw-broadcast-header-left">
|
||||||
|
<i className="fa-solid fa-broadcast-tower" />
|
||||||
|
<span>{isLive ? "LIVE" : "Go Live"}</span>
|
||||||
|
{isLive && (
|
||||||
|
<div className="sw-broadcast-live-badge">
|
||||||
|
<span className="sw-broadcast-dot" />
|
||||||
|
<span>{viewerCount} watching</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-broadcast-close"
|
||||||
|
onClick={() => {
|
||||||
|
if (isLive) {
|
||||||
|
setShowConfirm(true);
|
||||||
|
} else {
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach((t) => t.stop());
|
||||||
|
}
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="sw-broadcast-content">
|
||||||
|
{/* Video preview */}
|
||||||
|
<div className="sw-broadcast-video-area">
|
||||||
|
<div className="sw-broadcast-main-video">
|
||||||
|
{localStream ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
className="sw-broadcast-video"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="sw-broadcast-video-placeholder">
|
||||||
|
<i className="fa-solid fa-spinner fa-spin" />
|
||||||
|
<span>Starting camera...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLive && (
|
||||||
|
<div className="sw-broadcast-live-indicator">
|
||||||
|
<span className="sw-broadcast-dot" />
|
||||||
|
<span>LIVE on LiveKit</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side panel */}
|
||||||
|
<div className="sw-broadcast-side">
|
||||||
|
{!isLive && (
|
||||||
|
<div className="sw-broadcast-form">
|
||||||
|
<label className="sw-broadcast-label">Stream Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="sw-broadcast-input"
|
||||||
|
placeholder="What's happening?"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<div className="sw-broadcast-hint">
|
||||||
|
Using LiveKit - works on 5G/4G/WiFi
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLive && (
|
||||||
|
<div className="sw-broadcast-live-info">
|
||||||
|
<h3 className="sw-broadcast-title">{title}</h3>
|
||||||
|
<div className="sw-broadcast-stats">
|
||||||
|
<span>
|
||||||
|
<i className="fa-solid fa-eye" /> {viewerCount} viewers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="sw-broadcast-error">
|
||||||
|
<i className="fa-solid fa-exclamation-triangle" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sw-broadcast-actions">
|
||||||
|
{isLive ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-broadcast-btn danger"
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-stop" />
|
||||||
|
<span>End Stream</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-broadcast-btn primary"
|
||||||
|
onClick={handleGoLive}
|
||||||
|
disabled={isConnecting || !localStream}
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-spinner fa-spin" />
|
||||||
|
<span>Connecting...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-broadcast-tower" />
|
||||||
|
<span>Go Live</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm dialog */}
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="sw-broadcast-confirm-overlay">
|
||||||
|
<div className="sw-broadcast-confirm">
|
||||||
|
<h3>End Stream?</h3>
|
||||||
|
<p>Your live stream will end for all viewers.</p>
|
||||||
|
<div className="sw-broadcast-confirm-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-broadcast-btn secondary"
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-broadcast-btn danger"
|
||||||
|
onClick={() => handleEndStream(false)}
|
||||||
|
>
|
||||||
|
End Stream
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-broadcast-btn primary"
|
||||||
|
onClick={() => handleEndStream(true)}
|
||||||
|
>
|
||||||
|
End & Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Room, RoomEvent } from "livekit-client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import "./Live.css";
|
||||||
|
|
||||||
|
const LIVEKIT_URL = "wss://live.us1.sociowire.com";
|
||||||
|
const LIVEKIT_API = "https://live.us1.sociowire.com";
|
||||||
|
|
||||||
|
function roomNameFromPost(post) {
|
||||||
|
const raw = (post?.embed_url || post?.video_url || "").toString().trim();
|
||||||
|
if (raw.startsWith("livekit://")) {
|
||||||
|
return raw.slice("livekit://".length);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveKitViewerModal({ post, onClose }) {
|
||||||
|
const { username, token: authToken } = useAuth();
|
||||||
|
const userID = username || `viewer_${Date.now()}`;
|
||||||
|
const roomName = roomNameFromPost(post);
|
||||||
|
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [viewerCount, setViewerCount] = useState(0);
|
||||||
|
const [videoTrack, setVideoTrack] = useState(null);
|
||||||
|
const roomRef = useRef(null);
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
|
||||||
|
const getToken = useCallback(async () => {
|
||||||
|
const res = await fetch(`${LIVEKIT_API}/api/livekit/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
room: roomName,
|
||||||
|
identity: userID,
|
||||||
|
name: username || userID,
|
||||||
|
canPublish: false,
|
||||||
|
canSubscribe: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to get LiveKit token");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.token;
|
||||||
|
}, [authToken, roomName, userID, username]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roomName) {
|
||||||
|
setError("Missing room name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mounted = true;
|
||||||
|
const connect = async () => {
|
||||||
|
setIsConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!mounted) return;
|
||||||
|
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||||
|
roomRef.current = room;
|
||||||
|
|
||||||
|
room.on(RoomEvent.TrackSubscribed, (track) => {
|
||||||
|
if (track.kind === "video") {
|
||||||
|
setVideoTrack(track);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.ParticipantConnected, () => {
|
||||||
|
setViewerCount((prev) => prev + 1);
|
||||||
|
});
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, () => {
|
||||||
|
setViewerCount((prev) => Math.max(0, prev - 1));
|
||||||
|
});
|
||||||
|
room.on(RoomEvent.Disconnected, () => {
|
||||||
|
setViewerCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await room.connect(LIVEKIT_URL, token);
|
||||||
|
setViewerCount(room.participants?.size || 0);
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted) {
|
||||||
|
setError(err.message || "Failed to connect");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setIsConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
if (roomRef.current) {
|
||||||
|
roomRef.current.disconnect();
|
||||||
|
roomRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [roomName, getToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoRef.current || !videoTrack) return;
|
||||||
|
videoTrack.attach(videoRef.current);
|
||||||
|
return () => {
|
||||||
|
try { videoTrack.detach(videoRef.current); } catch {}
|
||||||
|
};
|
||||||
|
}, [videoTrack]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (roomRef.current) {
|
||||||
|
roomRef.current.disconnect();
|
||||||
|
roomRef.current = null;
|
||||||
|
}
|
||||||
|
onClose?.();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
if (!post) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sw-viewer-modal" onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) handleClose();
|
||||||
|
}}>
|
||||||
|
<div className="sw-viewer-container">
|
||||||
|
<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">{post?.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}>
|
||||||
|
<i className="fa-solid fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sw-viewer-content">
|
||||||
|
<div className="sw-viewer-video-area">
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<div className="sw-viewer-main-video">
|
||||||
|
<video ref={videoRef} autoPlay playsInline controls />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,337 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import "./Live.css";
|
||||||
|
|
||||||
|
const MAPTILER_KEY = "DvwhzlPmC55ZK28nLtXL";
|
||||||
|
|
||||||
|
function formatRegionLabel(region) {
|
||||||
|
if (!region) return "Unknown";
|
||||||
|
return region.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoModal({ camera, onClose }) {
|
||||||
|
const mapContainerRef = useRef(null);
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!camera) return;
|
||||||
|
|
||||||
|
// Escape key handler
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose && onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [camera, onClose]);
|
||||||
|
|
||||||
|
// Initialize mini map in modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainerRef.current || !camera) return;
|
||||||
|
if (mapRef.current) return;
|
||||||
|
|
||||||
|
const lng = Number(camera.lng);
|
||||||
|
const lat = Number(camera.lat);
|
||||||
|
if (!Number.isFinite(lng) || !Number.isFinite(lat)) 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: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add marker for camera
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "sw-camera-marker is-selected";
|
||||||
|
el.innerHTML = `<i class="fa-solid fa-video"></i>`;
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(map);
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
}, [camera]);
|
||||||
|
|
||||||
|
if (!camera) return null;
|
||||||
|
|
||||||
|
const name = camera.name_fr || camera.name_en || "Camera";
|
||||||
|
const region = formatRegionLabel(camera.region);
|
||||||
|
const route = camera.route || "";
|
||||||
|
const embedUrl = camera.embed_url || "";
|
||||||
|
const videoUrl = camera.video_url || "";
|
||||||
|
const lat = camera.lat;
|
||||||
|
const lng = camera.lng;
|
||||||
|
|
||||||
|
const handleBackdropClick = (e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose && onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
const shareUrl = embedUrl || videoUrl || "";
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({ title: name, text: `Live camera: ${name}`, url: shareUrl }).catch(() => {});
|
||||||
|
} else if (navigator.clipboard && shareUrl) {
|
||||||
|
navigator.clipboard.writeText(shareUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenExternal = () => {
|
||||||
|
const url = embedUrl || videoUrl;
|
||||||
|
if (url) {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sw-video-modal-backdrop" onClick={handleBackdropClick}>
|
||||||
|
<div className="sw-video-modal">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sw-video-modal-header">
|
||||||
|
<div className="sw-video-modal-avatar">
|
||||||
|
<i className="fa-solid fa-video" />
|
||||||
|
</div>
|
||||||
|
<div className="sw-video-modal-meta">
|
||||||
|
<div className="sw-video-modal-title">{name}</div>
|
||||||
|
<div className="sw-video-modal-subtitle">
|
||||||
|
{region}
|
||||||
|
{route ? ` • Route ${route}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sw-video-modal-live-badge">
|
||||||
|
<span className="sw-camera-dot" />
|
||||||
|
<span>LIVE</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-video-modal-close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Player */}
|
||||||
|
<div className="sw-video-modal-content">
|
||||||
|
<div className="sw-video-player">
|
||||||
|
{embedUrl ? (
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
title={name}
|
||||||
|
allow="autoplay; encrypted-media"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : videoUrl ? (
|
||||||
|
<video
|
||||||
|
src={videoUrl}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
controls
|
||||||
|
style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="sw-video-player-fallback">
|
||||||
|
<i className="fa-solid fa-video-slash" />
|
||||||
|
<span>Stream unavailable</span>
|
||||||
|
{camera.video_url && (
|
||||||
|
<a
|
||||||
|
href={camera.video_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Open direct link
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location Info with Mini Map */}
|
||||||
|
<div className="sw-video-modal-info">
|
||||||
|
<div className="sw-video-modal-location">
|
||||||
|
<i className="fa-solid fa-map-marker-alt" />
|
||||||
|
<span>{region}</span>
|
||||||
|
{lat && lng && (
|
||||||
|
<span style={{ opacity: 0.7, fontSize: "0.75rem" }}>
|
||||||
|
({Number(lat).toFixed(4)}, {Number(lng).toFixed(4)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div ref={mapContainerRef} className="sw-video-modal-map" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="sw-video-modal-actions">
|
||||||
|
<button type="button" className="sw-wall-btn" onClick={handleShare}>
|
||||||
|
<i className="fa-solid fa-share-nodes" /> Share
|
||||||
|
</button>
|
||||||
|
<button type="button" className="sw-wall-btn" onClick={handleOpenExternal}>
|
||||||
|
<i className="fa-solid fa-external-link" /> Open External
|
||||||
|
</button>
|
||||||
|
{camera.video_url && (
|
||||||
|
<a
|
||||||
|
href={camera.video_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="sw-wall-btn"
|
||||||
|
style={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-link" /> Direct Stream
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
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";
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
// 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: 'camera' })
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing camera stream with swap capability
|
||||||
|
* Most mobile devices can't open both cameras simultaneously,
|
||||||
|
* so we open one at a time and swap on demand.
|
||||||
|
*/
|
||||||
|
export function useDualCamera() {
|
||||||
|
const [mainStream, setMainStream] = useState(null);
|
||||||
|
const [selfStream, setSelfStream] = useState(null); // Always null on mobile (no simultaneous dual cam)
|
||||||
|
const [isSwapped, setIsSwapped] = useState(false); // false = front camera, true = back camera
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [isSwapping, setIsSwapping] = useState(false);
|
||||||
|
const [permissions, setPermissions] = useState({ video: false, audio: false });
|
||||||
|
const [canSwap, setCanSwap] = useState(false); // True if we detected 2 cameras
|
||||||
|
|
||||||
|
// Track available camera device IDs
|
||||||
|
const frontDeviceIdRef = useRef(null);
|
||||||
|
const backDeviceIdRef = useRef(null);
|
||||||
|
const currentStreamRef = useRef(null);
|
||||||
|
const audioStreamRef = useRef(null); // Keep audio separate so we don't lose it on swap
|
||||||
|
|
||||||
|
const checkPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const hasVideo = devices.some((d) => d.kind === "videoinput");
|
||||||
|
const hasAudio = devices.some((d) => d.kind === "audioinput");
|
||||||
|
setPermissions({ video: hasVideo, audio: hasAudio });
|
||||||
|
return { video: hasVideo, audio: hasAudio };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[useDualCamera] Permission check failed:", err);
|
||||||
|
return { video: false, audio: false };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Track both streams for dual camera mode (when device supports it)
|
||||||
|
const frontStreamRef = useRef(null);
|
||||||
|
const backStreamRef = useRef(null);
|
||||||
|
|
||||||
|
const startCameras = useCallback(async () => {
|
||||||
|
if (isStarting) return;
|
||||||
|
setIsStarting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First request any camera to get permissions (labels are empty until permission granted)
|
||||||
|
const tempStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||||
|
tempStream.getTracks().forEach(t => t.stop());
|
||||||
|
|
||||||
|
// Small delay after releasing temp stream
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
// Now list available cameras with labels
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
||||||
|
remoteLog("log", "[useDualCamera] Available cameras:", videoDevices.map(d => ({
|
||||||
|
id: d.deviceId?.slice(0,8),
|
||||||
|
label: d.label,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Try to identify front vs back by label
|
||||||
|
for (const device of videoDevices) {
|
||||||
|
const label = (device.label || "").toLowerCase();
|
||||||
|
if (label.includes("front") || label.includes("user") || label.includes("selfie")) {
|
||||||
|
frontDeviceIdRef.current = device.deviceId;
|
||||||
|
} else if (label.includes("back") || label.includes("rear") || label.includes("environment")) {
|
||||||
|
backDeviceIdRef.current = device.deviceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't identify by label, use first two devices
|
||||||
|
if (!frontDeviceIdRef.current && !backDeviceIdRef.current && videoDevices.length >= 2) {
|
||||||
|
frontDeviceIdRef.current = videoDevices[0].deviceId;
|
||||||
|
backDeviceIdRef.current = videoDevices[1].deviceId;
|
||||||
|
} else if (!frontDeviceIdRef.current && videoDevices.length >= 1) {
|
||||||
|
frontDeviceIdRef.current = videoDevices[0].deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTwoCameras = !!(frontDeviceIdRef.current && backDeviceIdRef.current);
|
||||||
|
setCanSwap(hasTwoCameras);
|
||||||
|
|
||||||
|
remoteLog("log", "[useDualCamera] Detected devices:", {
|
||||||
|
front: frontDeviceIdRef.current?.slice(0,8),
|
||||||
|
back: backDeviceIdRef.current?.slice(0,8),
|
||||||
|
hasTwoCameras
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open FRONT camera first (main view with audio)
|
||||||
|
remoteLog("log", "[useDualCamera] Opening front camera...");
|
||||||
|
const frontStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: frontDeviceIdRef.current
|
||||||
|
? { deviceId: { exact: frontDeviceIdRef.current }, width: { ideal: 1280 }, height: { ideal: 720 } }
|
||||||
|
: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||||
|
audio: true,
|
||||||
|
});
|
||||||
|
frontStreamRef.current = frontStream;
|
||||||
|
currentStreamRef.current = frontStream;
|
||||||
|
audioStreamRef.current = frontStream; // Audio comes from this stream
|
||||||
|
|
||||||
|
const frontTrack = frontStream.getVideoTracks()[0];
|
||||||
|
remoteLog("log", "[useDualCamera] Front camera opened:", frontTrack?.label);
|
||||||
|
|
||||||
|
// Try to open BACK camera simultaneously (for PiP on devices that support it)
|
||||||
|
let backStream = null;
|
||||||
|
if (hasTwoCameras) {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
try {
|
||||||
|
remoteLog("log", "[useDualCamera] Trying to open back camera simultaneously...");
|
||||||
|
backStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: backDeviceIdRef.current
|
||||||
|
? { deviceId: { exact: backDeviceIdRef.current }, width: { ideal: 640 }, height: { ideal: 480 } }
|
||||||
|
: { facingMode: { exact: "environment" }, width: { ideal: 640 }, height: { ideal: 480 } },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
backStreamRef.current = backStream;
|
||||||
|
const backTrack = backStream.getVideoTracks()[0];
|
||||||
|
remoteLog("log", "[useDualCamera] Back camera opened (dual mode):", backTrack?.label);
|
||||||
|
} catch (backErr) {
|
||||||
|
remoteLog("warn", "[useDualCamera] Back camera failed (single cam mode):", backErr.name, backErr.message);
|
||||||
|
// Device doesn't support dual cameras - that's ok, we'll swap on demand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMainStream(frontStream);
|
||||||
|
setSelfStream(backStream);
|
||||||
|
setIsSwapped(false);
|
||||||
|
setPermissions({ video: true, audio: true });
|
||||||
|
|
||||||
|
remoteLog("log", "[useDualCamera] Cameras started:", {
|
||||||
|
front: !!frontStream,
|
||||||
|
back: !!backStream,
|
||||||
|
canSwap: hasTwoCameras
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
remoteLog("error", "[useDualCamera] Failed to start cameras:", err.name, err.message);
|
||||||
|
setError(err.message || "Failed to access camera");
|
||||||
|
|
||||||
|
if (err.name === "NotAllowedError") {
|
||||||
|
setError("Camera permission denied. Please allow camera access.");
|
||||||
|
} else if (err.name === "NotFoundError") {
|
||||||
|
setError("No camera found on this device.");
|
||||||
|
} else if (err.name === "NotReadableError") {
|
||||||
|
setError("Camera is already in use by another app.");
|
||||||
|
} else if (err.name === "OverconstrainedError") {
|
||||||
|
setError("Camera constraints not supported. Try again.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
}, [isStarting]);
|
||||||
|
|
||||||
|
// Swap cameras - works in both dual and single camera modes
|
||||||
|
const swapCameras = useCallback(async () => {
|
||||||
|
if (!canSwap || isSwapping) {
|
||||||
|
remoteLog("warn", "[useDualCamera] Cannot swap - no second camera or already swapping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have both streams open (dual camera mode)
|
||||||
|
if (frontStreamRef.current && backStreamRef.current) {
|
||||||
|
// Dual camera mode - just swap the streams
|
||||||
|
setIsSwapped((prev) => {
|
||||||
|
const newSwapped = !prev;
|
||||||
|
remoteLog("log", "[useDualCamera] Swapping streams (dual mode), newSwapped:", newSwapped);
|
||||||
|
|
||||||
|
if (newSwapped) {
|
||||||
|
setMainStream(backStreamRef.current);
|
||||||
|
setSelfStream(frontStreamRef.current);
|
||||||
|
currentStreamRef.current = backStreamRef.current;
|
||||||
|
} else {
|
||||||
|
setMainStream(frontStreamRef.current);
|
||||||
|
setSelfStream(backStreamRef.current);
|
||||||
|
currentStreamRef.current = frontStreamRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSwapped;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single camera mode - close current and open other
|
||||||
|
setIsSwapping(true);
|
||||||
|
const newSwapped = !isSwapped;
|
||||||
|
remoteLog("log", "[useDualCamera] Swapping cameras (single mode), switching to:", newSwapped ? "back" : "front");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Stop current video track (keep audio)
|
||||||
|
const currentStream = currentStreamRef.current;
|
||||||
|
if (currentStream) {
|
||||||
|
currentStream.getVideoTracks().forEach(t => t.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the other camera
|
||||||
|
const targetDeviceId = newSwapped ? backDeviceIdRef.current : frontDeviceIdRef.current;
|
||||||
|
const facingMode = newSwapped ? "environment" : "user";
|
||||||
|
|
||||||
|
remoteLog("log", "[useDualCamera] Opening camera:", { targetDeviceId: targetDeviceId?.slice(0,8), facingMode });
|
||||||
|
|
||||||
|
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: targetDeviceId
|
||||||
|
? { deviceId: { exact: targetDeviceId }, width: { ideal: 1280 }, height: { ideal: 720 } }
|
||||||
|
: { facingMode: { exact: facingMode }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||||
|
audio: false, // Don't request audio again
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a combined stream with new video + existing audio
|
||||||
|
const audioTrack = audioStreamRef.current?.getAudioTracks()[0];
|
||||||
|
const combinedStream = new MediaStream();
|
||||||
|
newStream.getVideoTracks().forEach(t => combinedStream.addTrack(t));
|
||||||
|
if (audioTrack) {
|
||||||
|
combinedStream.addTrack(audioTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update refs
|
||||||
|
if (newSwapped) {
|
||||||
|
if (frontStreamRef.current) {
|
||||||
|
frontStreamRef.current.getVideoTracks().forEach(t => t.stop());
|
||||||
|
}
|
||||||
|
backStreamRef.current = newStream;
|
||||||
|
frontStreamRef.current = null;
|
||||||
|
} else {
|
||||||
|
if (backStreamRef.current) {
|
||||||
|
backStreamRef.current.getVideoTracks().forEach(t => t.stop());
|
||||||
|
}
|
||||||
|
frontStreamRef.current = newStream;
|
||||||
|
backStreamRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStreamRef.current = combinedStream;
|
||||||
|
setMainStream(combinedStream);
|
||||||
|
setSelfStream(null); // No PiP in single camera mode
|
||||||
|
setIsSwapped(newSwapped);
|
||||||
|
|
||||||
|
const newTrack = newStream.getVideoTracks()[0];
|
||||||
|
remoteLog("log", "[useDualCamera] Camera swapped to:", newTrack?.label);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
remoteLog("error", "[useDualCamera] Failed to swap cameras:", err.name, err.message);
|
||||||
|
setError("Failed to swap cameras: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setIsSwapping(false);
|
||||||
|
}
|
||||||
|
}, [canSwap, isSwapped, isSwapping]);
|
||||||
|
|
||||||
|
const stopCameras = useCallback(() => {
|
||||||
|
if (frontStreamRef.current) {
|
||||||
|
frontStreamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
frontStreamRef.current = null;
|
||||||
|
}
|
||||||
|
if (backStreamRef.current) {
|
||||||
|
backStreamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
backStreamRef.current = null;
|
||||||
|
}
|
||||||
|
if (audioStreamRef.current && audioStreamRef.current !== frontStreamRef.current) {
|
||||||
|
audioStreamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
}
|
||||||
|
audioStreamRef.current = null;
|
||||||
|
currentStreamRef.current = null;
|
||||||
|
frontDeviceIdRef.current = null;
|
||||||
|
backDeviceIdRef.current = null;
|
||||||
|
|
||||||
|
setMainStream(null);
|
||||||
|
setSelfStream(null);
|
||||||
|
setIsSwapped(false);
|
||||||
|
setError(null);
|
||||||
|
setCanSwap(false);
|
||||||
|
|
||||||
|
remoteLog("log", "[useDualCamera] Cameras stopped");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mute/unmute audio
|
||||||
|
const toggleAudio = useCallback((muted) => {
|
||||||
|
const stream = audioStreamRef.current;
|
||||||
|
if (stream) {
|
||||||
|
stream.getAudioTracks().forEach((track) => {
|
||||||
|
track.enabled = !muted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Enable/disable video
|
||||||
|
const toggleVideo = useCallback((disabled) => {
|
||||||
|
const stream = currentStreamRef.current;
|
||||||
|
if (stream) {
|
||||||
|
stream.getVideoTracks().forEach((track) => {
|
||||||
|
track.enabled = !disabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopCameras();
|
||||||
|
};
|
||||||
|
}, [stopCameras]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mainStream,
|
||||||
|
selfStream,
|
||||||
|
isSwapped,
|
||||||
|
error,
|
||||||
|
isStarting,
|
||||||
|
isSwapping,
|
||||||
|
permissions,
|
||||||
|
hasDualCameras: canSwap, // Can we swap between cameras?
|
||||||
|
startCameras,
|
||||||
|
swapCameras,
|
||||||
|
stopCameras,
|
||||||
|
toggleAudio,
|
||||||
|
toggleVideo,
|
||||||
|
checkPermissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDualCamera;
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
VideoPresets,
|
||||||
|
createLocalVideoTrack,
|
||||||
|
createLocalAudioTrack,
|
||||||
|
} from "livekit-client";
|
||||||
|
|
||||||
|
const LIVEKIT_URL = "wss://live.us1.sociowire.com";
|
||||||
|
const LIVEKIT_API = "https://live.us1.sociowire.com";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for LiveKit-based live streaming
|
||||||
|
* Much more reliable than raw WebRTC - handles TURN/ICE automatically
|
||||||
|
*/
|
||||||
|
export function useLiveKit({ roomName, userID, username, token: authToken }) {
|
||||||
|
const [isLive, setIsLive] = useState(false);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [viewerCount, setViewerCount] = useState(0);
|
||||||
|
const [participants, setParticipants] = useState([]);
|
||||||
|
|
||||||
|
const roomRef = useRef(null);
|
||||||
|
const localVideoTrackRef = useRef(null);
|
||||||
|
const localAudioTrackRef = useRef(null);
|
||||||
|
|
||||||
|
// Get LiveKit token from backend
|
||||||
|
const getToken = useCallback(async (isHost = true) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${LIVEKIT_API}/api/livekit/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
room: roomName,
|
||||||
|
identity: userID,
|
||||||
|
name: username,
|
||||||
|
canPublish: isHost,
|
||||||
|
canSubscribe: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to get LiveKit token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.token;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[LiveKit] Token error:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [roomName, userID, username, authToken]);
|
||||||
|
|
||||||
|
// Start broadcasting
|
||||||
|
const startBroadcast = useCallback(async (videoTrack, audioEnabled = true) => {
|
||||||
|
if (isConnecting || isLive) return;
|
||||||
|
|
||||||
|
setIsConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get token
|
||||||
|
const token = await getToken(true);
|
||||||
|
|
||||||
|
// Create room
|
||||||
|
const room = new Room({
|
||||||
|
adaptiveStream: true,
|
||||||
|
dynacast: true,
|
||||||
|
videoCaptureDefaults: {
|
||||||
|
resolution: VideoPresets.h720.resolution,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up event handlers
|
||||||
|
room.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||||
|
console.log("[LiveKit] Participant joined:", participant.identity);
|
||||||
|
setParticipants((prev) => [
|
||||||
|
...prev.filter((p) => p.identity !== participant.identity),
|
||||||
|
{
|
||||||
|
identity: participant.identity,
|
||||||
|
name: participant.name || participant.identity,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setViewerCount((prev) => prev + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
||||||
|
console.log("[LiveKit] Participant left:", participant.identity);
|
||||||
|
setParticipants((prev) =>
|
||||||
|
prev.filter((p) => p.identity !== participant.identity)
|
||||||
|
);
|
||||||
|
setViewerCount((prev) => Math.max(0, prev - 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.Disconnected, () => {
|
||||||
|
console.log("[LiveKit] Disconnected from room");
|
||||||
|
setIsLive(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.Reconnecting, () => {
|
||||||
|
console.log("[LiveKit] Reconnecting...");
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.Reconnected, () => {
|
||||||
|
console.log("[LiveKit] Reconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to room
|
||||||
|
await room.connect(LIVEKIT_URL, token);
|
||||||
|
console.log("[LiveKit] Connected to room:", room.name);
|
||||||
|
|
||||||
|
// Create and publish video track from provided stream
|
||||||
|
if (videoTrack) {
|
||||||
|
// If a MediaStreamTrack is provided, use it
|
||||||
|
const track = videoTrack instanceof MediaStreamTrack
|
||||||
|
? videoTrack
|
||||||
|
: videoTrack.getVideoTracks()[0];
|
||||||
|
|
||||||
|
if (track) {
|
||||||
|
await room.localParticipant.publishTrack(track, {
|
||||||
|
name: "camera",
|
||||||
|
simulcast: true,
|
||||||
|
videoCodec: "vp8",
|
||||||
|
});
|
||||||
|
localVideoTrackRef.current = track;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create default camera track
|
||||||
|
const videoTrack = await createLocalVideoTrack({
|
||||||
|
facingMode: "user",
|
||||||
|
resolution: VideoPresets.h720.resolution,
|
||||||
|
});
|
||||||
|
await room.localParticipant.publishTrack(videoTrack);
|
||||||
|
localVideoTrackRef.current = videoTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and publish audio track
|
||||||
|
if (audioEnabled) {
|
||||||
|
const audioTrack = await createLocalAudioTrack();
|
||||||
|
await room.localParticipant.publishTrack(audioTrack);
|
||||||
|
localAudioTrackRef.current = audioTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
roomRef.current = room;
|
||||||
|
setIsLive(true);
|
||||||
|
console.log("[LiveKit] Broadcasting started");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[LiveKit] Failed to start broadcast:", err);
|
||||||
|
setError(err.message || "Failed to start broadcast");
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
}, [isConnecting, isLive, getToken]);
|
||||||
|
|
||||||
|
// Stop broadcasting
|
||||||
|
const stopBroadcast = useCallback(async () => {
|
||||||
|
if (roomRef.current) {
|
||||||
|
// Unpublish tracks
|
||||||
|
if (localVideoTrackRef.current) {
|
||||||
|
roomRef.current.localParticipant.unpublishTrack(localVideoTrackRef.current);
|
||||||
|
localVideoTrackRef.current.stop();
|
||||||
|
localVideoTrackRef.current = null;
|
||||||
|
}
|
||||||
|
if (localAudioTrackRef.current) {
|
||||||
|
roomRef.current.localParticipant.unpublishTrack(localAudioTrackRef.current);
|
||||||
|
localAudioTrackRef.current.stop();
|
||||||
|
localAudioTrackRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect from room
|
||||||
|
await roomRef.current.disconnect();
|
||||||
|
roomRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLive(false);
|
||||||
|
setViewerCount(0);
|
||||||
|
setParticipants([]);
|
||||||
|
console.log("[LiveKit] Broadcast stopped");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle audio
|
||||||
|
const toggleAudio = useCallback((muted) => {
|
||||||
|
if (localAudioTrackRef.current) {
|
||||||
|
localAudioTrackRef.current.enabled = !muted;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle video
|
||||||
|
const toggleVideo = useCallback((disabled) => {
|
||||||
|
if (localVideoTrackRef.current) {
|
||||||
|
localVideoTrackRef.current.enabled = !disabled;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (roomRef.current) {
|
||||||
|
roomRef.current.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLive,
|
||||||
|
isConnecting,
|
||||||
|
error,
|
||||||
|
viewerCount,
|
||||||
|
participants,
|
||||||
|
startBroadcast,
|
||||||
|
stopBroadcast,
|
||||||
|
toggleAudio,
|
||||||
|
toggleVideo,
|
||||||
|
room: roomRef.current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useLiveKit;
|
||||||
|
|
@ -0,0 +1,537 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,426 @@
|
||||||
|
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;
|
||||||
|
|
@ -6,6 +6,7 @@ import "../../styles/posts.css";
|
||||||
import "../../styles/filters.css";
|
import "../../styles/filters.css";
|
||||||
|
|
||||||
import { createPost, fetchPostPreview, resolveAssetRef } from "../../api/client";
|
import { createPost, fetchPostPreview, resolveAssetRef } from "../../api/client";
|
||||||
|
import { LiveKitBroadcast, LiveKitViewerModal } from "../Live";
|
||||||
import {
|
import {
|
||||||
FILTER_MAIN_CATEGORIES,
|
FILTER_MAIN_CATEGORIES,
|
||||||
FILTER_CATEGORY_MAP,
|
FILTER_CATEGORY_MAP,
|
||||||
|
|
@ -174,6 +175,11 @@ export default function MapView({
|
||||||
|
|
||||||
// Profile state (selectedIsland is now passed from App.jsx)
|
// Profile state (selectedIsland is now passed from App.jsx)
|
||||||
const [selectedProfile, setSelectedProfile] = useState(null);
|
const [selectedProfile, setSelectedProfile] = useState(null);
|
||||||
|
|
||||||
|
// Live broadcast state
|
||||||
|
const [showLiveBroadcast, setShowLiveBroadcast] = useState(false);
|
||||||
|
const [activeLivePost, setActiveLivePost] = useState(null);
|
||||||
|
const [liveCoords, setLiveCoords] = useState(null);
|
||||||
const openedPostRef = useRef(null);
|
const openedPostRef = useRef(null);
|
||||||
const queryPostRef = useRef(false);
|
const queryPostRef = useRef(false);
|
||||||
const overlayTimerRef = useRef(null);
|
const overlayTimerRef = useRef(null);
|
||||||
|
|
@ -255,7 +261,7 @@ export default function MapView({
|
||||||
[mapRef]
|
[mapRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { status, debugStatus, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate } =
|
const { status, debugStatus, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate, handlePostDelete } =
|
||||||
usePostsEngine({
|
usePostsEngine({
|
||||||
theme,
|
theme,
|
||||||
mapRef,
|
mapRef,
|
||||||
|
|
@ -711,12 +717,58 @@ export default function MapView({
|
||||||
}
|
}
|
||||||
if (msg.type === "post_update") {
|
if (msg.type === "post_update") {
|
||||||
handlePostUpdate(msg);
|
handlePostUpdate(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "post_delete") {
|
||||||
|
handlePostDelete(msg);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => ws && ws.close();
|
return () => ws && ws.close();
|
||||||
}, [handleIncomingPost, handlePostUpdate]);
|
}, [handleIncomingPost, handlePostUpdate, handlePostDelete]);
|
||||||
|
|
||||||
|
// Listen for live post created event (from LiveBroadcastModal)
|
||||||
|
useEffect(() => {
|
||||||
|
const onLivePostCreated = (e) => {
|
||||||
|
if (e.detail && e.detail.id) {
|
||||||
|
handleIncomingPost(e.detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('live-post-created', onLivePostCreated);
|
||||||
|
return () => window.removeEventListener('live-post-created', onLivePostCreated);
|
||||||
|
}, [handleIncomingPost]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onLivePostUpdated = (e) => {
|
||||||
|
if (e.detail && e.detail.id) {
|
||||||
|
handlePostUpdate({ post_id: e.detail.id, post: e.detail });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onLivePostDeleted = (e) => {
|
||||||
|
const postId = e.detail?.post_id || e.detail?.id;
|
||||||
|
if (postId) {
|
||||||
|
handlePostDelete({ post_id: postId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('live-post-updated', onLivePostUpdated);
|
||||||
|
window.addEventListener('live-post-deleted', onLivePostDeleted);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('live-post-updated', onLivePostUpdated);
|
||||||
|
window.removeEventListener('live-post-deleted', onLivePostDeleted);
|
||||||
|
};
|
||||||
|
}, [handlePostUpdate, handlePostDelete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onLiveOpen = (e) => {
|
||||||
|
if (e.detail) {
|
||||||
|
setActiveLivePost(e.detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('livekit-open', onLiveOpen);
|
||||||
|
return () => window.removeEventListener('livekit-open', onLiveOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFlyToMe = () => {
|
const handleFlyToMe = () => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|
@ -1853,6 +1905,32 @@ export default function MapView({
|
||||||
type="button"
|
type="button"
|
||||||
className="chip-pill create-panel-btn primary"
|
className="chip-pill create-panel-btn primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
// For live mode, open broadcast modal after step 0 (crosshair placement)
|
||||||
|
if (contentMode === "live" && createStep === 0) {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (map) {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
const crosshairEl = stage ? stage.querySelector(".crosshair-aim") : null;
|
||||||
|
const containerEl = map.getContainer();
|
||||||
|
let coords = null;
|
||||||
|
if (crosshairEl && containerEl) {
|
||||||
|
const crossRect = crosshairEl.getBoundingClientRect();
|
||||||
|
const containerRect = containerEl.getBoundingClientRect();
|
||||||
|
const px = crossRect.left + crossRect.width / 2 - containerRect.left;
|
||||||
|
const py = crossRect.top + crossRect.height / 2 - containerRect.top;
|
||||||
|
const correctedLngLat = map.unproject([px, py]);
|
||||||
|
coords = [correctedLngLat.lng, correctedLngLat.lat];
|
||||||
|
} else {
|
||||||
|
const center = map.getCenter();
|
||||||
|
coords = [center.lng, center.lat];
|
||||||
|
}
|
||||||
|
setLiveCoords(coords);
|
||||||
|
setShowLiveBroadcast(true);
|
||||||
|
setIsCreating(false);
|
||||||
|
setModeChosen(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (createStep < 2) {
|
if (createStep < 2) {
|
||||||
setCreateStep((prev) => prev + 1);
|
setCreateStep((prev) => prev + 1);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1861,7 +1939,7 @@ export default function MapView({
|
||||||
}}
|
}}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
{createStep === 2 ? (isSaving ? "Posting..." : "Post") : "Next"}
|
{createStep === 2 ? (isSaving ? "Posting..." : "Post") : (contentMode === "live" && createStep === 0 ? "Go Live" : "Next")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1878,6 +1956,27 @@ export default function MapView({
|
||||||
onVisitIsland={handleProfileVisitIsland}
|
onVisitIsland={handleProfileVisitIsland}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Live Broadcast Modal - Using LiveKit */}
|
||||||
|
{showLiveBroadcast && (
|
||||||
|
<LiveKitBroadcast
|
||||||
|
coords={liveCoords}
|
||||||
|
onClose={() => {
|
||||||
|
setShowLiveBroadcast(false);
|
||||||
|
setLiveCoords(null);
|
||||||
|
}}
|
||||||
|
onStreamCreated={({ roomName, title, coords }) => {
|
||||||
|
console.log("[MapView] LiveKit stream created:", roomName, title, coords);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeLivePost && (
|
||||||
|
<LiveKitViewerModal
|
||||||
|
post={activeLivePost}
|
||||||
|
onClose={() => setActiveLivePost(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,9 @@ function applyPostDetails(modalWrap, post) {
|
||||||
const summaryEl = modalWrap.querySelector(".sw-modal-summary");
|
const summaryEl = modalWrap.querySelector(".sw-modal-summary");
|
||||||
if (summaryEl) summaryEl.textContent = summary;
|
if (summaryEl) summaryEl.textContent = summary;
|
||||||
|
|
||||||
|
// Skip hero replacement for camera posts - they have video content
|
||||||
|
const isCamera = post?.content_type === 'camera';
|
||||||
|
if (!isCamera) {
|
||||||
const heroGallery = collectGalleryImages(post);
|
const heroGallery = collectGalleryImages(post);
|
||||||
const heroImageSrc = heroGallery[0] || heroImageForPost(post);
|
const heroImageSrc = heroGallery[0] || heroImageForPost(post);
|
||||||
const heroFullSrc = heroGallery[0] || fullImageForPost(post);
|
const heroFullSrc = heroGallery[0] || fullImageForPost(post);
|
||||||
|
|
@ -258,6 +261,7 @@ function applyPostDetails(modalWrap, post) {
|
||||||
initModalGallery(modalWrap, heroGallery);
|
initModalGallery(modalWrap, heroGallery);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const badge = modalWrap.querySelector(".sw-modal-badge");
|
const badge = modalWrap.querySelector(".sw-modal-badge");
|
||||||
if (badge) badge.textContent = normCatLabel(post);
|
if (badge) badge.textContent = normCatLabel(post);
|
||||||
|
|
@ -411,6 +415,23 @@ function escapeHtml(str) {
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract camera ID from Quebec 511 URL like https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3978
|
||||||
|
function extractCameraId(url) {
|
||||||
|
if (!url) return null;
|
||||||
|
const match = url.match(/[?&]id=(\d+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build stream proxy URL for camera
|
||||||
|
function getCameraStreamUrl(postData) {
|
||||||
|
const embedUrl = postData?.embed_url || postData?.video_url || '';
|
||||||
|
const cameraId = extractCameraId(embedUrl);
|
||||||
|
if (cameraId) {
|
||||||
|
return `/live/api/stream/${cameraId}`;
|
||||||
|
}
|
||||||
|
return embedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTagPills(tags) {
|
function renderTagPills(tags) {
|
||||||
if (!Array.isArray(tags) || tags.length === 0) return "";
|
if (!Array.isArray(tags) || tags.length === 0) return "";
|
||||||
const uniq = [];
|
const uniq = [];
|
||||||
|
|
@ -818,9 +839,159 @@ function closeCenteredOverlay(map, opts = {}) {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCameraOverlay({ map, post, theme, fullScreen, interactionState }) {
|
||||||
|
const postData = post || {};
|
||||||
|
const title = postData.title || "Live Camera";
|
||||||
|
const region = postData.region || "";
|
||||||
|
const embedUrl = postData.embed_url || "";
|
||||||
|
const videoUrl = postData.video_url || "";
|
||||||
|
const lat = postData.lat;
|
||||||
|
const lon = postData.lon;
|
||||||
|
const postID = postData.id || 0;
|
||||||
|
|
||||||
|
trackEvent("camera_open", {
|
||||||
|
post_id: postID,
|
||||||
|
source: postData.source || "",
|
||||||
|
region: region,
|
||||||
|
});
|
||||||
|
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "sw-centered-backdrop sw-camera-backdrop";
|
||||||
|
if (fullScreen) backdrop.classList.add("sw-modal-fullscreen");
|
||||||
|
backdrop.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999999;
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const modalWrap = document.createElement("div");
|
||||||
|
modalWrap.className = "sw-camera-modal";
|
||||||
|
modalWrap.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 90vh;
|
||||||
|
background: rgba(15, 23, 42, 0.98);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.6);
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(56,189,248,0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const coordsInfo = (lat && lon) ? `(${Number(lat).toFixed(4)}, ${Number(lon).toFixed(4)})` : "";
|
||||||
|
|
||||||
|
modalWrap.innerHTML = `
|
||||||
|
<div class="sw-camera-modal-header" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;background:rgba(15,23,42,0.95);border-bottom:1px solid rgba(148,163,184,0.3);">
|
||||||
|
<div style="width:42px;height:42px;border-radius:12px;background:linear-gradient(135deg,rgba(56,189,248,0.25),rgba(59,130,246,0.25));border:1px solid rgba(56,189,248,0.6);display:flex;align-items:center;justify-content:center;">
|
||||||
|
<i class="fa-solid fa-video" style="font-size:18px;color:#38bdf8;"></i>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:1rem;font-weight:800;color:#f8fafc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(title)}</div>
|
||||||
|
<div style="font-size:0.78rem;color:#94a3b8;">${escapeHtml(region)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.35rem;padding:0.3rem 0.7rem;border-radius:999px;background:rgba(239,68,68,0.85);color:#fff;font-size:0.7rem;font-weight:800;letter-spacing:0.08em;">
|
||||||
|
<span style="width:6px;height:6px;border-radius:50%;background:#fff;animation:sw-camera-pulse 1.5s ease-in-out infinite;"></span>
|
||||||
|
LIVE
|
||||||
|
</div>
|
||||||
|
<button class="sw-camera-close" style="width:36px;height:36px;border-radius:50%;border:1px solid rgba(148,163,184,0.4);background:rgba(15,23,42,0.8);color:#e5e7eb;font-size:1.1rem;display:flex;align-items:center;justify-content:center;cursor:pointer;">
|
||||||
|
<i class="fa-solid fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;min-height:0;overflow:auto;">
|
||||||
|
<div style="position:relative;width:100%;padding-top:56.25%;background:linear-gradient(135deg,rgba(15,23,42,1),rgba(30,41,59,1));">
|
||||||
|
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1.5rem;padding:2rem;">
|
||||||
|
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,rgba(56,189,248,0.2),rgba(59,130,246,0.2));border:2px solid rgba(56,189,248,0.4);display:flex;align-items:center;justify-content:center;">
|
||||||
|
<i class="fa-solid fa-video" style="font-size:32px;color:#38bdf8;"></i>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-size:1.1rem;font-weight:700;color:#f8fafc;margin-bottom:0.5rem;">Live Traffic Camera</div>
|
||||||
|
<div style="font-size:0.85rem;color:#94a3b8;">Click below to view the live stream</div>
|
||||||
|
</div>
|
||||||
|
${(embedUrl || videoUrl)
|
||||||
|
? `<a href="${escapeHtml(embedUrl || videoUrl)}" target="_blank" rel="noopener noreferrer" style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.75rem 1.5rem;background:linear-gradient(135deg,#0ea5e9,#3b82f6);color:#fff;font-weight:700;font-size:0.9rem;border-radius:999px;text-decoration:none;box-shadow:0 4px 15px rgba(14,165,233,0.4);">
|
||||||
|
<i class="fa-solid fa-external-link-alt"></i>
|
||||||
|
Open Live Stream
|
||||||
|
</a>`
|
||||||
|
: `<div style="color:#94a3b8;font-size:0.85rem;">Stream URL unavailable</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0.75rem 1rem;border-top:1px solid rgba(148,163,184,0.2);">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.8rem;color:#94a3b8;margin-bottom:0.5rem;">
|
||||||
|
<i class="fa-solid fa-map-marker-alt" style="color:#38bdf8;"></i>
|
||||||
|
<span>${escapeHtml(region)}</span>
|
||||||
|
${coordsInfo ? `<span style="opacity:0.7;font-size:0.75rem;">${coordsInfo}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;padding:0.75rem 1rem;border-top:1px solid rgba(148,163,184,0.2);flex-wrap:wrap;">
|
||||||
|
<button class="sw-camera-share sw-wall-btn" style="border:1px solid rgba(56,189,248,0.35);background:rgba(15,23,42,0.6);color:#e2f2ff;font-weight:800;font-size:0.75rem;padding:0.35rem 0.6rem;border-radius:999px;cursor:pointer;">
|
||||||
|
<i class="fa-solid fa-share-nodes"></i> Share
|
||||||
|
</button>
|
||||||
|
${embedUrl ? `<a href="${escapeHtml(embedUrl)}" target="_blank" rel="noopener noreferrer" class="sw-wall-btn" style="border:1px solid rgba(56,189,248,0.35);background:rgba(15,23,42,0.6);color:#e2f2ff;font-weight:800;font-size:0.75rem;padding:0.35rem 0.6rem;border-radius:999px;cursor:pointer;text-decoration:none;">
|
||||||
|
<i class="fa-solid fa-external-link"></i> Open External
|
||||||
|
</a>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
try {
|
||||||
|
backdrop.remove();
|
||||||
|
map.__swCenteredOverlay = null;
|
||||||
|
if (interactionState.dragPan) map.dragPan?.enable?.();
|
||||||
|
if (interactionState.scrollZoom) map.scrollZoom?.enable?.();
|
||||||
|
if (interactionState.doubleClickZoom) map.doubleClickZoom?.enable?.();
|
||||||
|
if (interactionState.touchZoomRotate) map.touchZoomRotate?.enable?.();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
backdrop.addEventListener("click", (e) => {
|
||||||
|
if (e.target === backdrop) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeBtn = modalWrap.querySelector(".sw-camera-close");
|
||||||
|
if (closeBtn) closeBtn.addEventListener("click", closeModal);
|
||||||
|
|
||||||
|
const shareBtn = modalWrap.querySelector(".sw-camera-share");
|
||||||
|
if (shareBtn) {
|
||||||
|
shareBtn.addEventListener("click", () => {
|
||||||
|
const shareUrl = embedUrl || videoUrl || "";
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({ title, url: shareUrl }).catch(() => {});
|
||||||
|
} else if (navigator.clipboard && shareUrl) {
|
||||||
|
navigator.clipboard.writeText(shareUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function escHandler(e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
closeModal();
|
||||||
|
document.removeEventListener("keydown", escHandler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
backdrop.appendChild(modalWrap);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
map.__swCenteredOverlay = { el: backdrop, postId: postID };
|
||||||
|
}
|
||||||
|
|
||||||
export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
|
if (post?.content_type === "live") {
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new CustomEvent("livekit-open", { detail: post }));
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true });
|
closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true });
|
||||||
|
|
||||||
const interactionState = {
|
const interactionState = {
|
||||||
|
|
@ -835,6 +1006,21 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {}
|
try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {}
|
||||||
|
|
||||||
let postData = { ...(post || {}) };
|
let postData = { ...(post || {}) };
|
||||||
|
|
||||||
|
// Debug: log post data to see if content_type is present
|
||||||
|
console.log('[openCenteredOverlay] postData:', {
|
||||||
|
id: postData.id,
|
||||||
|
content_type: postData.content_type,
|
||||||
|
source: postData.source,
|
||||||
|
title: postData.title?.substring(0, 30)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Camera posts now use the regular modal with video content
|
||||||
|
const isCamera = postData.content_type === 'camera';
|
||||||
|
if (isCamera) {
|
||||||
|
console.log('[openCenteredOverlay] Opening camera as regular post with video');
|
||||||
|
}
|
||||||
|
|
||||||
const data = adaptPostToTemplateData(postData, { size: "full" });
|
const data = adaptPostToTemplateData(postData, { size: "full" });
|
||||||
const cat = normCatLabel(postData);
|
const cat = normCatLabel(postData);
|
||||||
const templateKey = getTemplateKeyForPost(postData);
|
const templateKey = getTemplateKeyForPost(postData);
|
||||||
|
|
@ -975,7 +1161,14 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
<div class="post-card sw-expanded-shell sw-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
|
<div class="post-card sw-expanded-shell sw-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
|
||||||
<div class="sw-modal-toprow">
|
<div class="sw-modal-toprow">
|
||||||
<div class="sw-modal-left">
|
<div class="sw-modal-left">
|
||||||
|
${isCamera ? `
|
||||||
|
<div class="sw-modal-badge" style="background:linear-gradient(135deg,#dc2626,#ef4444);display:flex;align-items:center;gap:0.4rem;">
|
||||||
|
<span style="width:6px;height:6px;border-radius:50%;background:#fff;animation:pulse 1.5s infinite;"></span>
|
||||||
|
LIVE CAMERA
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
||||||
|
`}
|
||||||
<div class="sw-modal-meta">
|
<div class="sw-modal-meta">
|
||||||
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
||||||
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
||||||
|
|
@ -988,6 +1181,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sw-modal-hero-row">
|
<div class="sw-modal-hero-row">
|
||||||
|
${!isCamera ? `
|
||||||
<div class="sw-truth-rail" data-score="${truth}">
|
<div class="sw-truth-rail" data-score="${truth}">
|
||||||
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
|
||||||
<div class="sw-truth-rail-bar">
|
<div class="sw-truth-rail-bar">
|
||||||
|
|
@ -996,9 +1190,27 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
<div class="sw-truth-rail-score">${truth}</div>
|
<div class="sw-truth-rail-score">${truth}</div>
|
||||||
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sw-modal-hero">
|
` : ''}
|
||||||
${
|
<div class="sw-modal-hero" ${isCamera ? 'style="flex:1;max-width:100%;"' : ''}>
|
||||||
img
|
${isCamera ? `
|
||||||
|
<div class="sw-camera-hero" style="position:relative;width:100%;padding-top:56.25%;background:#000;border-radius:12px;overflow:hidden;">
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
loop
|
||||||
|
style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;"
|
||||||
|
src="${escapeHtml(getCameraStreamUrl(postData))}"
|
||||||
|
>
|
||||||
|
<source src="${escapeHtml(getCameraStreamUrl(postData))}" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
<div class="sw-camera-live-badge" style="position:absolute;top:12px;left:12px;display:flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(220,38,38,0.9);border-radius:4px;font-size:11px;font-weight:700;color:#fff;text-transform:uppercase;">
|
||||||
|
<span style="width:6px;height:6px;border-radius:50%;background:#fff;animation:pulse 1.5s infinite;"></span>
|
||||||
|
LIVE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : img
|
||||||
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
|
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
|
||||||
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
|
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
|
||||||
}
|
}
|
||||||
|
|
@ -1020,15 +1232,21 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sw-modal-cta-row">
|
<div class="sw-modal-cta-row">
|
||||||
|
${isCamera ? `
|
||||||
|
<a class="sw-cta-primary sw-cta-camera" href="${escapeHtml(postData.video_url || postData.embed_url || '')}" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:0.5rem;text-decoration:none;background:linear-gradient(135deg,#dc2626,#ef4444);">
|
||||||
|
<i class="fa-solid fa-video"></i> Watch Live Stream
|
||||||
|
</a>
|
||||||
|
` : `
|
||||||
<button class="sw-cta-primary" type="button" ${url ? "" : "disabled"} data-url="${escapeHtml(url)}">
|
<button class="sw-cta-primary" type="button" ${url ? "" : "disabled"} data-url="${escapeHtml(url)}">
|
||||||
Open source
|
Open source
|
||||||
</button>
|
</button>
|
||||||
|
`}
|
||||||
|
|
||||||
<div class="sw-cta-actions">
|
<div class="sw-cta-actions">
|
||||||
<button class="post-card-btn" type="button">Contact</button>
|
${!isCamera ? `<button class="post-card-btn" type="button">Contact</button>` : ''}
|
||||||
<button class="post-card-btn" type="button" data-action="like">Like</button>
|
<button class="post-card-btn" type="button" data-action="like">Like</button>
|
||||||
<button class="post-card-btn" type="button" data-action="share">Share</button>
|
<button class="post-card-btn" type="button" data-action="share">Share</button>
|
||||||
<button class="post-card-btn" type="button">Fix</button>
|
${!isCamera ? `<button class="post-card-btn" type="button">Fix</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1539,14 +1757,17 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
root.addEventListener("click", (ev) => {
|
root.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// Use root.__post to get the most up-to-date post data (may have been refreshed)
|
||||||
|
const currentPost = root.__post || post;
|
||||||
|
|
||||||
const existing = map.__swCenteredOverlay;
|
const existing = map.__swCenteredOverlay;
|
||||||
if (existing && existing.postId === post?.id) {
|
if (existing && existing.postId === currentPost?.id) {
|
||||||
if (existing.openedAt && Date.now() - existing.openedAt < 800) return;
|
if (existing.openedAt && Date.now() - existing.openedAt < 800) return;
|
||||||
closeCenteredOverlay(map, { force: true });
|
closeCenteredOverlay(map, { force: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openCenteredOverlay({ map, post, theme, fullScreen: false });
|
openCenteredOverlay({ map, post: currentPost, theme, fullScreen: false });
|
||||||
if (expandedElRef) expandedElRef.current = null;
|
if (expandedElRef) expandedElRef.current = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1592,7 +1813,20 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
|
|
||||||
function renderSimple() {
|
function renderSimple() {
|
||||||
const activePost = root.__post || post;
|
const activePost = root.__post || post;
|
||||||
const color = getMarkerColorForCategory(activePost?.category);
|
const isCamera = activePost?.content_type === 'camera';
|
||||||
|
const isLive = activePost?.content_type === 'live';
|
||||||
|
const liveColor = 'linear-gradient(135deg, rgba(220,38,38,0.95), rgba(239,68,68,0.95))';
|
||||||
|
const livePin = 'rgba(220,38,38,0.95)';
|
||||||
|
const color = isLive
|
||||||
|
? liveColor
|
||||||
|
: isCamera
|
||||||
|
? 'linear-gradient(135deg, rgba(56, 189, 248, 0.9), rgba(59, 130, 246, 0.9))'
|
||||||
|
: getMarkerColorForCategory(activePost?.category);
|
||||||
|
const pinColor = isLive
|
||||||
|
? livePin
|
||||||
|
: isCamera
|
||||||
|
? 'rgba(56, 189, 248, 0.9)'
|
||||||
|
: getMarkerColorForCategory(activePost?.category);
|
||||||
const img = normalizeImageUrl(activePost?.image_small || "");
|
const img = normalizeImageUrl(activePost?.image_small || "");
|
||||||
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
||||||
root.__lastImage = img || "";
|
root.__lastImage = img || "";
|
||||||
|
|
@ -1600,13 +1834,13 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
|
|
||||||
// Marqueur pin avec image en haut et pointe en bas
|
// Marqueur pin avec image en haut et pointe en bas
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="sw-simple-marker" style="
|
<div class="sw-simple-marker ${isCamera ? 'sw-camera-marker' : ''} ${isLive ? 'sw-live-marker' : ''}" style="
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: ${color};
|
background: ${color};
|
||||||
border: 2px solid white;
|
border: 2px solid ${isCamera ? 'rgba(255,255,255,0.85)' : isLive ? 'rgba(255,255,255,0.9)' : 'white'};
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3)${isCamera ? ', 0 0 10px rgba(56, 189, 248, 0.5)' : isLive ? ', 0 0 12px rgba(239,68,68,0.6)' : ''};
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -1614,7 +1848,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
">
|
">
|
||||||
${img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
${isLive ? `<i class="fa-solid fa-broadcast-tower" style="font-size:14px;color:#fff;"></i>` : isCamera ? `<i class="fa-solid fa-video" style="font-size:14px;color:#fff;"></i>` : img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
||||||
<div style="
|
<div style="
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
|
@ -1629,7 +1863,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
height: 0;
|
height: 0;
|
||||||
border-left: 8px solid transparent;
|
border-left: 8px solid transparent;
|
||||||
border-right: 8px solid transparent;
|
border-right: 8px solid transparent;
|
||||||
border-top: 16px solid ${color};
|
border-top: 16px solid ${pinColor};
|
||||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
@ -1653,6 +1887,9 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
">
|
">
|
||||||
|
${isCamera ? `<div style="display:inline-flex;align-items:center;gap:4px;padding:2px 6px;margin-bottom:4px;border-radius:999px;background:rgba(239,68,68,0.85);color:#fff;font-size:9px;font-weight:800;">
|
||||||
|
<span style="width:5px;height:5px;border-radius:50%;background:#fff;"></span>LIVE
|
||||||
|
</div>` : ""}
|
||||||
<div style="font-weight: 900; font-size: 12px; color: #e8f5ff; margin-bottom: 4px;">
|
<div style="font-weight: 900; font-size: 12px; color: #e8f5ff; margin-bottom: 4px;">
|
||||||
${escapeHtml(activePost?.title || "Untitled")}
|
${escapeHtml(activePost?.title || "Untitled")}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1660,7 +1897,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
${escapeHtml((activePost?.snippet || "").substring(0, 80))}${(activePost?.snippet || "").length > 80 ? "..." : ""}
|
${escapeHtml((activePost?.snippet || "").substring(0, 80))}${(activePost?.snippet || "").length > 80 ? "..." : ""}
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 6px; font-size: 9px; color: rgba(232, 245, 255, 0.6);">
|
<div style="margin-top: 6px; font-size: 9px; color: rgba(232, 245, 255, 0.6);">
|
||||||
${normCatLabel(activePost)} • ${formatRelativeTime(activePost?.created_at || activePost?.CreatedAt)}
|
${isCamera ? `<i class="fa-solid fa-video" style="margin-right:4px;"></i>` : ""}${normCatLabel(activePost)} • ${isCamera ? (activePost?.region || "Quebec") : formatRelativeTime(activePost?.created_at || activePost?.CreatedAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -1706,14 +1943,26 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
root.addEventListener("click", (ev) => {
|
root.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// Use root.__post to get the most up-to-date post data (may have been refreshed)
|
||||||
|
const currentPost = root.__post || post;
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('[SimpleMarker click] currentPost:', {
|
||||||
|
id: currentPost?.id,
|
||||||
|
content_type: currentPost?.content_type,
|
||||||
|
source: currentPost?.source,
|
||||||
|
hasRootPost: !!root.__post,
|
||||||
|
rootPostContentType: root.__post?.content_type
|
||||||
|
});
|
||||||
|
|
||||||
const existing = map.__swCenteredOverlay;
|
const existing = map.__swCenteredOverlay;
|
||||||
if (existing && existing.postId === post?.id) {
|
if (existing && existing.postId === currentPost?.id) {
|
||||||
if (existing.openedAt && Date.now() - existing.openedAt < 800) return;
|
if (existing.openedAt && Date.now() - existing.openedAt < 800) return;
|
||||||
closeCenteredOverlay(map, { force: true });
|
closeCenteredOverlay(map, { force: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openCenteredOverlay({ map, post, theme, fullScreen: false });
|
openCenteredOverlay({ map, post: currentPost, theme, fullScreen: false });
|
||||||
if (expandedElRef) expandedElRef.current = null;
|
if (expandedElRef) expandedElRef.current = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1398,5 +1398,34 @@ export function usePostsEngine({
|
||||||
[mapRef, refreshVisibleAndMarkers, timeHours]
|
[mapRef, refreshVisibleAndMarkers, timeHours]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { status, debugStatus, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate };
|
const handlePostDelete = useCallback(
|
||||||
|
(payload) => {
|
||||||
|
const postId = payload?.post_id || payload?.postId || payload?.id;
|
||||||
|
if (!postId) return;
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
const overlay = map?.__swCenteredOverlay;
|
||||||
|
if (overlay && overlay.postId === postId) {
|
||||||
|
try { overlay.el?.remove?.(); } catch {}
|
||||||
|
map.__swCenteredOverlay = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
allPostsRef.current = (allPostsRef.current || []).filter((p) => (p?.id || p?.ID) !== postId);
|
||||||
|
setVisiblePosts((current) => current.filter((p) => (p?.id || p?.ID) !== postId));
|
||||||
|
|
||||||
|
const markers = markersRef.current || [];
|
||||||
|
const nextMarkers = [];
|
||||||
|
for (const item of markers) {
|
||||||
|
if (!item || item.id !== postId) {
|
||||||
|
nextMarkers.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try { item.marker?.remove?.(); } catch {}
|
||||||
|
}
|
||||||
|
markersRef.current = nextMarkers;
|
||||||
|
},
|
||||||
|
[mapRef, markersRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { status, debugStatus, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate, handlePostDelete };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue