voice channel audio working again
This commit is contained in:
+10
-7
@@ -94,11 +94,14 @@ body {
|
||||
width: var(--channel-sidebar-width);
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.channel-sidebar {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -488,7 +491,7 @@ body {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.video-tile:hover .video-controls,
|
||||
.video-tile:hover .video-controls,
|
||||
.video-tile:hover .tile-actions-right {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -623,10 +626,10 @@ body {
|
||||
/* Connection Popover */
|
||||
.connection-popover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
margin-left: 12px;
|
||||
width: 220px;
|
||||
top: 100%;
|
||||
left: 12px;
|
||||
margin-top: 4px;
|
||||
width: 216px;
|
||||
background-color: var(--background-floating, #1e1f22);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
@@ -640,8 +643,8 @@ body {
|
||||
.connection-popover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
left: 20px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--background-floating);
|
||||
|
||||
@@ -7,7 +7,6 @@ import './App.css';
|
||||
// import { Identity } from 'spacetimedb';
|
||||
// import { useAuth } from "react-oidc-context";
|
||||
// import { TOKEN_KEY } from './main';
|
||||
// import { useWebRTC } from './useWebRTC';
|
||||
|
||||
// Import the new ChatContainer component
|
||||
import { ChatContainer } from './chat'; // Import from index.ts
|
||||
|
||||
+29
-31
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useChat } from './services';
|
||||
import { useChat, useWebRTC } from './services';
|
||||
import ServerList from './components/ServerList';
|
||||
import ChannelList from './components/ChannelList';
|
||||
import MessageList from './components/MessageList';
|
||||
@@ -9,9 +9,7 @@ import ThreadView from './components/ThreadView';
|
||||
import ServerDiscovery from './components/ServerDiscovery';
|
||||
import { VideoGrid } from './components/VideoGrid';
|
||||
import { SettingsPanel } from './components/SettingsPanel';
|
||||
import { useSpacetimeDB, useTable } from 'spacetimedb/react';
|
||||
import { tables } from '../module_bindings';
|
||||
import useWebRTC from '../useWebRTC';
|
||||
import { useSpacetimeDB } from 'spacetimedb/react';
|
||||
|
||||
const ChatContainer: React.FC = () => {
|
||||
const chat = useChat();
|
||||
@@ -19,12 +17,12 @@ const ChatContainer: React.FC = () => {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showMemberList, setShowMemberList] = useState(true);
|
||||
|
||||
const {
|
||||
peerStatuses,
|
||||
peers,
|
||||
localScreenStream,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
const {
|
||||
peerStatuses,
|
||||
peers,
|
||||
localScreenStream,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
isSharingScreen,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
@@ -42,7 +40,7 @@ const ChatContainer: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<ServerList
|
||||
<ServerList
|
||||
joinedServers={chat.joinedServers}
|
||||
activeServerId={chat.activeServerId}
|
||||
setActiveServerId={chat.setActiveServerId}
|
||||
@@ -57,9 +55,9 @@ const ChatContainer: React.FC = () => {
|
||||
setShowDiscoveryModal={chat.setShowDiscoveryModal}
|
||||
handleLeaveServer={chat.handleLeaveServer}
|
||||
/>
|
||||
|
||||
|
||||
<div className="sidebar-container">
|
||||
<ChannelList
|
||||
<ChannelList
|
||||
activeServerId={chat.activeServerId}
|
||||
activeChannelId={chat.activeChannelId}
|
||||
setActiveChannelId={chat.setActiveChannelId}
|
||||
@@ -99,8 +97,8 @@ const ChatContainer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="voice-actions">
|
||||
<button
|
||||
className="icon-btn"
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={chat.handleLeaveVoice}
|
||||
title="Disconnect"
|
||||
style={{ color: '#f23f43' }}
|
||||
@@ -125,22 +123,22 @@ const ChatContainer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-actions">
|
||||
<button
|
||||
className={`icon-btn ${isMuted ? 'active' : ''}`}
|
||||
<button
|
||||
className={`icon-btn ${isMuted ? 'active' : ''}`}
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? '🎙️❌' : '🎙️'}
|
||||
</button>
|
||||
<button
|
||||
className={`icon-btn ${isDeafened ? 'active' : ''}`}
|
||||
<button
|
||||
className={`icon-btn ${isDeafened ? 'active' : ''}`}
|
||||
onClick={toggleDeafen}
|
||||
title={isDeafened ? "Undeafen" : "Deafen"}
|
||||
>
|
||||
{isDeafened ? '🎧❌' : '🎧'}
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => setShowSettings(true)}
|
||||
title="User Settings"
|
||||
>
|
||||
@@ -158,10 +156,10 @@ const ChatContainer: React.FC = () => {
|
||||
</span>
|
||||
{chat.activeChannel?.name || 'Select a channel'}
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{chat.isActiveChannelVoice && chat.connectedVoiceChannel?.id === chat.activeChannel?.id && (
|
||||
<button
|
||||
<button
|
||||
className={`screen-share-btn ${isSharingScreen ? 'active' : ''}`}
|
||||
onClick={isSharingScreen ? stopScreenShare : startScreenShare}
|
||||
>
|
||||
@@ -170,7 +168,7 @@ const ChatContainer: React.FC = () => {
|
||||
)}
|
||||
|
||||
{!chat.activeThreadId && (
|
||||
<button
|
||||
<button
|
||||
className={`icon-btn ${showMemberList ? 'active' : ''}`}
|
||||
onClick={() => setShowMemberList(!showMemberList)}
|
||||
title={showMemberList ? "Hide Member List" : "Show Member List"}
|
||||
@@ -180,9 +178,9 @@ const ChatContainer: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{chat.isActiveChannelVoice ? (
|
||||
<VideoGrid
|
||||
<VideoGrid
|
||||
peers={peers}
|
||||
localScreenStream={localScreenStream}
|
||||
connectedChannelId={chat.activeChannel!.id}
|
||||
@@ -192,7 +190,7 @@ const ChatContainer: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MessageList
|
||||
<MessageList
|
||||
messages={chat.channelMessages}
|
||||
activeThreadId={chat.activeThreadId}
|
||||
setActiveThreadId={chat.setActiveThreadId}
|
||||
@@ -201,7 +199,7 @@ const ChatContainer: React.FC = () => {
|
||||
handleStartThread={chat.handleStartThread}
|
||||
isFullyAuthenticated={chat.isFullyAuthenticated}
|
||||
/>
|
||||
<MessageInput
|
||||
<MessageInput
|
||||
activeChannelId={chat.activeChannelId}
|
||||
activeThreadId={chat.activeThreadId}
|
||||
isFullyAuthenticated={chat.isFullyAuthenticated}
|
||||
@@ -212,7 +210,7 @@ const ChatContainer: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{chat.activeThreadId ? (
|
||||
<ThreadView
|
||||
<ThreadView
|
||||
activeThreadId={chat.activeThreadId}
|
||||
setActiveThreadId={chat.setActiveThreadId}
|
||||
activeChannelId={chat.activeChannelId}
|
||||
@@ -223,7 +221,7 @@ const ChatContainer: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
showMemberList && (
|
||||
<MemberList
|
||||
<MemberList
|
||||
activeServerMembers={chat.activeServerMembers}
|
||||
users={chat.users}
|
||||
identity={identity ?? null}
|
||||
@@ -235,7 +233,7 @@ const ChatContainer: React.FC = () => {
|
||||
)}
|
||||
|
||||
{chat.showDiscoveryModal && (
|
||||
<ServerDiscovery
|
||||
<ServerDiscovery
|
||||
availableServers={chat.availableServers}
|
||||
handleJoinServer={chat.handleJoinServer}
|
||||
onClose={() => chat.setShowDiscoveryModal(false)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import * as Types from "../../module_bindings/types";
|
||||
import { WebRTCStats } from "../../useWebRTC";
|
||||
import { WebRTCStats } from "../services";
|
||||
|
||||
interface ChannelListProps {
|
||||
activeServerId: bigint | null;
|
||||
@@ -58,7 +58,7 @@ const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: s
|
||||
<span className="popover-name">{name}</span>
|
||||
<span className={`popover-status ${getStatusColor(status)}`}>{status}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{isMe ? (
|
||||
<div className="popover-info">Local connection (sending only)</div>
|
||||
) : stats ? (
|
||||
@@ -160,9 +160,9 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
<span className="channel-item-hash">🔊</span>
|
||||
{channel.name}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Voice Channel Members */}
|
||||
<div style={{marginLeft: '24px', display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px'}}>
|
||||
<div style={{paddingLeft: '16px', display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px'}}>
|
||||
{voiceStates.filter(vs => vs.channelId === channel.id).map(vs => {
|
||||
const peerIdHex = vs.identity.toHexString();
|
||||
const isMe = identity?.isEqual(vs.identity);
|
||||
@@ -181,27 +181,27 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
else if (videoStatusColor === 'yellow' && finalStatusColor === 'green') finalStatusColor = 'yellow';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={peerIdHex}
|
||||
<div
|
||||
key={peerIdHex}
|
||||
className="voice-member-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-muted)',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-muted)',
|
||||
height: '24px',
|
||||
position: 'relative',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="avatar"
|
||||
<div
|
||||
className="avatar"
|
||||
style={{
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
fontSize: '0.5rem',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
fontSize: '0.5rem',
|
||||
backgroundColor: 'var(--brand)',
|
||||
border: isTalking ? '2px solid #23a559' : '2px solid transparent',
|
||||
boxShadow: isTalking ? '0 0 4px #23a559' : 'none',
|
||||
@@ -213,26 +213,26 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
</div>
|
||||
<span className="member-name" style={{ color: isTalking ? 'white' : 'inherit' }}>{getUsername(vs.identity, users)}</span>
|
||||
{isSharing && (
|
||||
<span style={{
|
||||
backgroundColor: '#f23f43',
|
||||
color: 'white',
|
||||
fontSize: '0.6rem',
|
||||
padding: '1px 4px',
|
||||
<span style={{
|
||||
backgroundColor: '#f23f43',
|
||||
color: 'white',
|
||||
fontSize: '0.6rem',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 'bold',
|
||||
marginLeft: '4px',
|
||||
flexShrink: 0
|
||||
}}>LIVE</span>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
padding: '0 4px',
|
||||
cursor: 'help'
|
||||
}}
|
||||
}}
|
||||
onMouseEnter={() => setHoveredPeer(peerIdHex)}
|
||||
onMouseLeave={() => setHoveredPeer(null)}
|
||||
>
|
||||
@@ -240,7 +240,7 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
</div>
|
||||
|
||||
{hoveredPeer === peerIdHex && (
|
||||
<ConnectionPopover
|
||||
<ConnectionPopover
|
||||
stats={peerStats.get(peerIdHex)}
|
||||
status={isMe ? 'connected' : (status || 'connecting')}
|
||||
name={getUsername(vs.identity, users)}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { useChat } from "./useChat";
|
||||
export { useWebRTC } from "./webrtc/useWebRTC";
|
||||
export type { WebRTCStats } from "./webrtc/types";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Identity } from "spacetimedb";
|
||||
|
||||
export interface Peer {
|
||||
pc: RTCPeerConnection;
|
||||
audio?: HTMLAudioElement;
|
||||
videoStream?: MediaStream;
|
||||
}
|
||||
|
||||
export interface WebRTCStats {
|
||||
audio: {
|
||||
bytesReceived: number;
|
||||
jitter: number;
|
||||
packetsLost: number;
|
||||
bitrate: number;
|
||||
};
|
||||
video: {
|
||||
bytesReceived: number;
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
framesPerSecond: number;
|
||||
bitrate: number;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface LocalMediaState {
|
||||
localStream: MediaStream | null;
|
||||
localScreenStream: MediaStream | null;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isTalking: boolean;
|
||||
isSharingScreen: boolean;
|
||||
toggleMute: () => void;
|
||||
toggleDeafen: () => void;
|
||||
startScreenShare: (peerManager: any) => Promise<void>;
|
||||
stopScreenShare: (peerManager: any) => void;
|
||||
requestMic: () => Promise<void>;
|
||||
releaseMic: () => void;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useReducer } from "spacetimedb/react";
|
||||
import { reducers } from "../../../module_bindings";
|
||||
|
||||
export const useLocalMedia = () => {
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [localScreenStream, setLocalScreenStream] = useState<MediaStream | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDeafened, setIsDeafened] = useState(false);
|
||||
const [isTalking, setIsTalking] = useState(false);
|
||||
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const localScreenStreamRef = useRef<MediaStream | null>(null);
|
||||
const isTalkingRef = useRef(false);
|
||||
|
||||
const setTalking = useReducer(reducers.setTalking);
|
||||
const setSharingScreen = useReducer(reducers.setSharingScreen);
|
||||
|
||||
const toggleMute = useCallback(() => setIsMuted(prev => !prev), []);
|
||||
const toggleDeafen = useCallback(() => setIsDeafened(prev => !prev), []);
|
||||
|
||||
const requestMic = useCallback(async () => {
|
||||
if (localStreamRef.current) return;
|
||||
try {
|
||||
console.log("[WebRTC] Requesting mic permission...");
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
setLocalStream(stream);
|
||||
localStreamRef.current = stream;
|
||||
} catch (err) {
|
||||
console.error("[WebRTC] Failed to get mic:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const releaseMic = useCallback(() => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
setLocalStream(null);
|
||||
localStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startScreenShare = useCallback(async (onTrackReady: (track: MediaStreamTrack) => void) => {
|
||||
try {
|
||||
console.log("[WebRTC] Requesting screen share...");
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||
setLocalScreenStream(stream);
|
||||
localScreenStreamRef.current = stream;
|
||||
setSharingScreen({ sharing: true });
|
||||
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
onTrackReady(videoTrack);
|
||||
videoTrack.onended = () => stopScreenShare(() => onTrackReady(null as any));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[WebRTC] Failed to start screen share:", err);
|
||||
}
|
||||
}, [setSharingScreen]);
|
||||
|
||||
const stopScreenShare = useCallback((onTrackCleared: (track: MediaStreamTrack | null) => void) => {
|
||||
if (localScreenStreamRef.current) {
|
||||
localScreenStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
setLocalScreenStream(null);
|
||||
localScreenStreamRef.current = null;
|
||||
setSharingScreen({ sharing: false });
|
||||
onTrackCleared(null);
|
||||
}
|
||||
}, [setSharingScreen]);
|
||||
|
||||
// Handle Mute/Deafen effect on tracks
|
||||
useEffect(() => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getAudioTracks().forEach(track => {
|
||||
track.enabled = !isMuted && !isDeafened;
|
||||
});
|
||||
}
|
||||
}, [isMuted, isDeafened]);
|
||||
|
||||
// Voice Activity Detection
|
||||
useEffect(() => {
|
||||
if (!localStream) {
|
||||
if (isTalkingRef.current) {
|
||||
setTalking({ talking: false });
|
||||
isTalkingRef.current = false;
|
||||
setIsTalking(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
const source = audioContext.createMediaStreamSource(localStream);
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
const threshold = 15;
|
||||
let silenceFrames = 0;
|
||||
const maxSilenceFrames = 10;
|
||||
|
||||
const checkAudio = setInterval(() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
|
||||
const average = sum / dataArray.length;
|
||||
|
||||
if (average > threshold) {
|
||||
silenceFrames = 0;
|
||||
if (!isTalkingRef.current) {
|
||||
setTalking({ talking: true });
|
||||
isTalkingRef.current = true;
|
||||
setIsTalking(true);
|
||||
}
|
||||
} else {
|
||||
silenceFrames++;
|
||||
if (silenceFrames > maxSilenceFrames && isTalkingRef.current) {
|
||||
setTalking({ talking: false });
|
||||
isTalkingRef.current = false;
|
||||
setIsTalking(false);
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearInterval(checkAudio);
|
||||
audioContext.close();
|
||||
};
|
||||
}, [localStream, setTalking]);
|
||||
|
||||
return {
|
||||
localStream,
|
||||
localScreenStream,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
isTalking,
|
||||
isSharingScreen: !!localScreenStream,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
requestMic,
|
||||
releaseMic,
|
||||
localStreamRef,
|
||||
localScreenStreamRef
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { Peer, WebRTCStats } from "./types";
|
||||
|
||||
const ICE_SERVERS: RTCConfiguration = {
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
};
|
||||
|
||||
export const usePeerManager = (
|
||||
identity: Identity | null,
|
||||
isDeafened: boolean,
|
||||
localStreamRef: React.MutableRefObject<MediaStream | null>,
|
||||
localScreenStreamRef: React.MutableRefObject<MediaStream | null>,
|
||||
onNegotiationNeeded: (peerIdHex: string, pc: RTCPeerConnection) => void,
|
||||
onIceCandidate: (peerIdHex: string, candidate: RTCIceCandidate) => void
|
||||
) => {
|
||||
const [peers, setPeers] = useState<Map<string, Peer>>(new Map());
|
||||
const [peerStatuses, setPeerStatuses] = useState<Map<string, string>>(new Map());
|
||||
const [peerStats, setPeerStats] = useState<Map<string, WebRTCStats>>(new Map());
|
||||
const peersRef = useRef<Map<string, Peer>>(new Map());
|
||||
const peerStatsRef = useRef<Map<string, WebRTCStats>>(new Map());
|
||||
|
||||
const getPeer = useCallback((peerIdHex: string) => peersRef.current.get(peerIdHex), []);
|
||||
|
||||
const createPeerConnection = useCallback((peerIdHex: string) => {
|
||||
if (peersRef.current.has(peerIdHex)) return peersRef.current.get(peerIdHex)!.pc;
|
||||
|
||||
if (identity && peerIdHex === identity.toHexString()) {
|
||||
console.warn(`[WebRTC] Attempted to create a PeerConnection to self (${peerIdHex}). Ignoring.`);
|
||||
return null as any; // Should not happen with proper filtering
|
||||
}
|
||||
|
||||
console.log(`[WebRTC] Creating new PeerConnection for ${peerIdHex}`);
|
||||
|
||||
const pc = new RTCPeerConnection(ICE_SERVERS);
|
||||
|
||||
// Bind handlers BEFORE adding transceivers to catch early negotiationneeded
|
||||
pc.onnegotiationneeded = () => {
|
||||
console.log(`[WebRTC] onnegotiationneeded fired for ${peerIdHex}`);
|
||||
onNegotiationNeeded(peerIdHex, pc);
|
||||
};
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
onIceCandidate(peerIdHex, event.candidate);
|
||||
}
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
console.log(`[WebRTC] ICE state for ${peerIdHex}: ${pc.iceConnectionState}`);
|
||||
setPeerStatuses(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(peerIdHex, pc.iceConnectionState);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (pc.iceConnectionState === 'failed') {
|
||||
console.log(`[WebRTC] ICE failed for ${peerIdHex}, closing peer for retry`);
|
||||
closePeer(peerIdHex);
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.log(`[WebRTC] Connection state for ${peerIdHex}: ${pc.connectionState}`);
|
||||
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
|
||||
console.log(`[WebRTC] Connection ${pc.connectionState} for ${peerIdHex}, cleaning up`);
|
||||
closePeer(peerIdHex);
|
||||
}
|
||||
};
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
console.log(`[WebRTC] Received track from ${peerIdHex}: ${event.track.kind} (id: ${event.track.id})`);
|
||||
setPeers(prev => {
|
||||
const next = new Map(prev);
|
||||
const existingPeer = { ...(next.get(peerIdHex) || { pc }) };
|
||||
|
||||
if (event.track.kind === 'audio') {
|
||||
if (!existingPeer.audio) {
|
||||
existingPeer.audio = new Audio();
|
||||
existingPeer.audio.autoplay = true;
|
||||
existingPeer.audio.muted = isDeafened;
|
||||
}
|
||||
|
||||
const currentAudioStream = (existingPeer.audio.srcObject instanceof MediaStream)
|
||||
? existingPeer.audio.srcObject
|
||||
: new MediaStream();
|
||||
|
||||
if (!currentAudioStream.getTracks().find(t => t.id === event.track.id)) {
|
||||
currentAudioStream.addTrack(event.track);
|
||||
}
|
||||
|
||||
if (existingPeer.audio.srcObject !== currentAudioStream) {
|
||||
existingPeer.audio.srcObject = currentAudioStream;
|
||||
}
|
||||
|
||||
existingPeer.audio.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.error(`[WebRTC] Error playing audio for ${peerIdHex}`, e);
|
||||
});
|
||||
} else if (event.track.kind === 'video') {
|
||||
|
||||
const currentVideoStream = existingPeer.videoStream || new MediaStream();
|
||||
if (!currentVideoStream.getTracks().find(t => t.id === event.track.id)) {
|
||||
currentVideoStream.addTrack(event.track);
|
||||
}
|
||||
// Force a new MediaStream object to trigger React re-render
|
||||
existingPeer.videoStream = new MediaStream(currentVideoStream.getTracks());
|
||||
}
|
||||
|
||||
next.set(peerIdHex, existingPeer);
|
||||
peersRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Fixed transceivers for stability
|
||||
pc.addTransceiver('audio', { direction: 'sendrecv' });
|
||||
pc.addTransceiver('video', { direction: 'sendrecv' });
|
||||
|
||||
// Initialize transceivers with local tracks if available
|
||||
const transceivers = pc.getTransceivers();
|
||||
if (localStreamRef.current) {
|
||||
const audioTrack = localStreamRef.current.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
console.log(`[WebRTC] Attaching local audio track to new connection for ${peerIdHex}`);
|
||||
transceivers[0].sender.replaceTrack(audioTrack);
|
||||
}
|
||||
}
|
||||
if (localScreenStreamRef.current) {
|
||||
const videoTrack = localScreenStreamRef.current.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
console.log(`[WebRTC] Attaching local video track to new connection for ${peerIdHex}`);
|
||||
transceivers[1].sender.replaceTrack(videoTrack);
|
||||
}
|
||||
}
|
||||
|
||||
peersRef.current.set(peerIdHex, { pc });
|
||||
setPeers(new Map(peersRef.current));
|
||||
return pc;
|
||||
}, [localStreamRef, localScreenStreamRef, onNegotiationNeeded, onIceCandidate]);
|
||||
|
||||
|
||||
// Sync isDeafened state to all peer audio elements
|
||||
useEffect(() => {
|
||||
peersRef.current.forEach(peer => {
|
||||
if (peer.audio) {
|
||||
peer.audio.muted = isDeafened;
|
||||
}
|
||||
});
|
||||
}, [isDeafened]);
|
||||
|
||||
const closePeer = useCallback((peerIdHex: string) => {
|
||||
|
||||
const peer = peersRef.current.get(peerIdHex);
|
||||
if (peer) {
|
||||
peer.pc.close();
|
||||
if (peer.audio) {
|
||||
peer.audio.pause();
|
||||
peer.audio.srcObject = null;
|
||||
}
|
||||
peersRef.current.delete(peerIdHex);
|
||||
setPeers(new Map(peersRef.current));
|
||||
setPeerStatuses(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(peerIdHex);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stats Polling
|
||||
useEffect(() => {
|
||||
if (peers.size === 0) {
|
||||
if (peerStatsRef.current.size > 0) {
|
||||
peerStatsRef.current = new Map();
|
||||
setPeerStats(new Map());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const newStats = new Map(peerStatsRef.current);
|
||||
for (const [peerIdHex, peer] of peers.entries()) {
|
||||
try {
|
||||
const stats = await peer.pc.getStats();
|
||||
const prevStats = peerStatsRef.current.get(peerIdHex);
|
||||
const currentStats: WebRTCStats = {
|
||||
audio: { bytesReceived: 0, jitter: 0, packetsLost: 0, bitrate: 0 },
|
||||
video: { bytesReceived: 0, frameWidth: 0, frameHeight: 0, framesPerSecond: 0, bitrate: 0 },
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'inbound-rtp') {
|
||||
const kind = report.kind;
|
||||
if (kind === 'audio' || kind === 'video') {
|
||||
const target = kind === 'audio' ? currentStats.audio : currentStats.video;
|
||||
target.bytesReceived = report.bytesReceived || 0;
|
||||
if (kind === 'audio') {
|
||||
currentStats.audio.jitter = report.jitter || 0;
|
||||
currentStats.audio.packetsLost = report.packetsLost || 0;
|
||||
} else {
|
||||
currentStats.video.frameWidth = report.frameWidth || 0;
|
||||
currentStats.video.frameHeight = report.frameHeight || 0;
|
||||
currentStats.video.framesPerSecond = report.framesPerSecond || 0;
|
||||
}
|
||||
|
||||
if (prevStats) {
|
||||
const prevTarget = kind === 'audio' ? prevStats.audio : prevStats.video;
|
||||
const deltaBytes = target.bytesReceived - prevTarget.bytesReceived;
|
||||
const deltaTime = (currentStats.timestamp - prevStats.timestamp) / 1000;
|
||||
if (deltaTime > 0) {
|
||||
target.bitrate = Math.max(0, deltaBytes * 8 / deltaTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
newStats.set(peerIdHex, currentStats);
|
||||
} catch (e) {
|
||||
console.warn(`[WebRTC] Failed to get stats for ${peerIdHex}`, e);
|
||||
}
|
||||
}
|
||||
peerStatsRef.current = newStats;
|
||||
setPeerStats(newStats);
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [peers]);
|
||||
|
||||
return {
|
||||
peers,
|
||||
peerStatuses,
|
||||
peerStats,
|
||||
createPeerConnection,
|
||||
closePeer,
|
||||
getPeer,
|
||||
peersRef
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { useTable, useReducer } from "spacetimedb/react";
|
||||
import { tables, reducers } from "../../../module_bindings";
|
||||
|
||||
export const useSignaling = (
|
||||
identity: Identity | null,
|
||||
connectedChannelId: bigint | undefined,
|
||||
createPeerConnection: (peerIdHex: string) => RTCPeerConnection,
|
||||
getPeer: (peerIdHex: string) => any,
|
||||
makingOfferRef: React.MutableRefObject<Map<string, boolean>>,
|
||||
ignoreOfferRef: React.MutableRefObject<Map<string, boolean>>
|
||||
) => {
|
||||
const [offers] = useTable(tables.sdp_offer);
|
||||
const [answers] = useTable(tables.sdp_answer);
|
||||
const [iceCandidates] = useTable(tables.ice_candidate);
|
||||
|
||||
const sendSdpAnswer = useReducer(reducers.sendSdpAnswer);
|
||||
|
||||
const processedOffersRef = useRef<Set<bigint>>(new Set());
|
||||
const processedAnswersRef = useRef<Set<bigint>>(new Set());
|
||||
const processedCandidatesRef = useRef<Set<bigint>>(new Set());
|
||||
const candidateQueueRef = useRef<Map<string, any[]>>(new Map());
|
||||
|
||||
|
||||
const drainCandidateQueue = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
|
||||
const queue = candidateQueueRef.current.get(peerIdHex) || [];
|
||||
if (queue.length === 0) return;
|
||||
|
||||
// Safety: ensure we have a remote description before draining
|
||||
if (!pc.remoteDescription) {
|
||||
console.warn(`[WebRTC] Attempted to drain candidates for ${peerIdHex} but no remote description exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[WebRTC] Draining ${queue.length} queued candidates for ${peerIdHex}`);
|
||||
for (const cand of queue) {
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(cand));
|
||||
} catch (e) {
|
||||
console.warn(`[WebRTC] Error adding queued ICE for ${peerIdHex}`, e);
|
||||
}
|
||||
}
|
||||
candidateQueueRef.current.set(peerIdHex, []);
|
||||
}, []);
|
||||
|
||||
// Handle Offers
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const myOffers = offers.filter(o =>
|
||||
o.receiver.isEqual(identity) &&
|
||||
!o.sender.isEqual(identity) &&
|
||||
o.channelId === connectedChannelId
|
||||
);
|
||||
|
||||
const processOffers = async () => {
|
||||
for (const offerRow of myOffers) {
|
||||
if (processedOffersRef.current.has(offerRow.id)) continue;
|
||||
// Mark as processed immediately to prevent duplicate processing during async gaps
|
||||
processedOffersRef.current.add(offerRow.id);
|
||||
|
||||
const peerIdHex = offerRow.sender.toHexString();
|
||||
console.log(`[WebRTC] Received offer from ${peerIdHex}`);
|
||||
const pc = createPeerConnection(peerIdHex);
|
||||
if (!pc) continue;
|
||||
const offer = JSON.parse(offerRow.sdp);
|
||||
|
||||
try {
|
||||
const isPolite = identity.toHexString() < peerIdHex;
|
||||
const makingOffer = makingOfferRef.current.get(peerIdHex) || false;
|
||||
const offerCollision = (pc.signalingState !== "stable") || makingOffer;
|
||||
|
||||
const ignoreOffer = !isPolite && offerCollision;
|
||||
ignoreOfferRef.current.set(peerIdHex, ignoreOffer);
|
||||
if (ignoreOffer) {
|
||||
console.log(`[WebRTC] Ignoring offer collision from ${peerIdHex} (Impolite)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (offerCollision) {
|
||||
console.log(`[WebRTC] Handling offer collision from ${peerIdHex} (Polite), rolling back...`);
|
||||
await pc.setLocalDescription({ type: "rollback" as RTCSdpType });
|
||||
}
|
||||
|
||||
console.log(`[WebRTC] Setting remote description for ${peerIdHex}`);
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
console.log(`[WebRTC] Sending answer to ${peerIdHex}`);
|
||||
sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId });
|
||||
|
||||
await drainCandidateQueue(peerIdHex, pc);
|
||||
} catch (e) {
|
||||
console.error(`[WebRTC] Error handling offer from ${peerIdHex}`, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
processOffers();
|
||||
}, [offers, connectedChannelId, identity, createPeerConnection, sendSdpAnswer, drainCandidateQueue]);
|
||||
|
||||
// Handle Answers
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const myAnswers = answers.filter(a =>
|
||||
a.receiver.isEqual(identity) &&
|
||||
!a.sender.isEqual(identity) &&
|
||||
a.channelId === connectedChannelId
|
||||
);
|
||||
|
||||
const processAnswers = async () => {
|
||||
for (const answerRow of myAnswers) {
|
||||
if (processedAnswersRef.current.has(answerRow.id)) continue;
|
||||
processedAnswersRef.current.add(answerRow.id);
|
||||
|
||||
const peerIdHex = answerRow.sender.toHexString();
|
||||
const peer = getPeer(peerIdHex);
|
||||
if (peer) {
|
||||
try {
|
||||
console.log(`[WebRTC] Received answer from ${peerIdHex}`);
|
||||
const answer = JSON.parse(answerRow.sdp);
|
||||
await peer.pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
await drainCandidateQueue(peerIdHex, peer.pc);
|
||||
} catch (e) { console.error(`[WebRTC] Error handling answer from ${peerIdHex}`, e); }
|
||||
} else {
|
||||
console.warn(`[WebRTC] Received answer from ${peerIdHex} but no PeerConnection exists`);
|
||||
}
|
||||
}
|
||||
};
|
||||
processAnswers();
|
||||
}, [answers, connectedChannelId, identity, getPeer, drainCandidateQueue]);
|
||||
|
||||
// Handle ICE Candidates
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const myCandidates = iceCandidates.filter(c =>
|
||||
c.receiver.isEqual(identity) &&
|
||||
!c.sender.isEqual(identity) &&
|
||||
c.channelId === connectedChannelId
|
||||
);
|
||||
|
||||
const processCandidates = async () => {
|
||||
for (const candRow of myCandidates) {
|
||||
if (processedCandidatesRef.current.has(candRow.id)) continue;
|
||||
processedCandidatesRef.current.add(candRow.id);
|
||||
|
||||
const peerIdHex = candRow.sender.toHexString();
|
||||
// Ensure PeerConnection exists if we get a candidate
|
||||
const pc = createPeerConnection(peerIdHex);
|
||||
if (!pc) continue;
|
||||
|
||||
try {
|
||||
const ignoreOffer = ignoreOfferRef.current.get(peerIdHex) || false;
|
||||
const candidate = JSON.parse(candRow.candidate);
|
||||
|
||||
if (pc.remoteDescription) {
|
||||
console.log(`[WebRTC] Adding ICE candidate from ${peerIdHex}`);
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} else if (!ignoreOffer) {
|
||||
console.log(`[WebRTC] Queueing ICE candidate from ${peerIdHex}`);
|
||||
const queue = candidateQueueRef.current.get(peerIdHex) || [];
|
||||
queue.push(candidate);
|
||||
candidateQueueRef.current.set(peerIdHex, queue);
|
||||
} else {
|
||||
console.log(`[WebRTC] Ignoring ICE candidate from ${peerIdHex} (ignoreOffer=true)`);
|
||||
}
|
||||
} catch (e) { console.error(`[WebRTC] Error handling ICE from ${peerIdHex}`, e); }
|
||||
}
|
||||
};
|
||||
processCandidates();
|
||||
}, [iceCandidates, connectedChannelId, identity, createPeerConnection]);
|
||||
|
||||
|
||||
|
||||
|
||||
const clearSignalingState = useCallback(() => {
|
||||
processedOffersRef.current.clear();
|
||||
processedAnswersRef.current.clear();
|
||||
processedCandidatesRef.current.clear();
|
||||
makingOfferRef.current.clear();
|
||||
ignoreOfferRef.current.clear();
|
||||
candidateQueueRef.current.clear();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
makingOfferRef,
|
||||
clearSignalingState
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react";
|
||||
import { tables, reducers } from "../../../module_bindings";
|
||||
import { useLocalMedia } from "./useLocalMedia";
|
||||
import { usePeerManager } from "./usePeerManager";
|
||||
import { useSignaling } from "./useSignaling";
|
||||
|
||||
export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
const { identity } = useSpacetimeDB();
|
||||
const [voiceStates] = useTable(tables.voice_state);
|
||||
const [watching] = useTable(tables.watching);
|
||||
|
||||
const sendSdpOffer = useReducer(reducers.sendSdpOffer);
|
||||
const sendIceCandidate = useReducer(reducers.sendIceCandidate);
|
||||
const startWatchingReducer = useReducer(reducers.startWatching);
|
||||
const stopWatchingReducer = useReducer(reducers.stopWatching);
|
||||
|
||||
// Refs for signaling state to avoid circular dependencies and stale closures
|
||||
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
|
||||
const ignoreOfferRef = useRef<Map<string, boolean>>(new Map());
|
||||
const connectedChannelIdRef = useRef<bigint | undefined>(connectedChannelId);
|
||||
|
||||
useEffect(() => {
|
||||
connectedChannelIdRef.current = connectedChannelId;
|
||||
}, [connectedChannelId]);
|
||||
|
||||
const {
|
||||
localStream,
|
||||
localScreenStream,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
isTalking,
|
||||
isSharingScreen,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
startScreenShare: startLocalScreenShare,
|
||||
stopScreenShare: stopLocalScreenShare,
|
||||
requestMic,
|
||||
releaseMic,
|
||||
localStreamRef,
|
||||
localScreenStreamRef
|
||||
} = useLocalMedia();
|
||||
|
||||
const onNegotiationNeeded = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
|
||||
// Always check the LATEST channel ID from ref
|
||||
const channelId = connectedChannelIdRef.current;
|
||||
const isMakingOffer = makingOfferRef.current.get(peerIdHex);
|
||||
|
||||
if (!channelId || pc.signalingState !== 'stable' || isMakingOffer) {
|
||||
console.log(`[WebRTC] Skipping negotiation for ${peerIdHex}: channel=${!!channelId}, state=${pc.signalingState}, makingOffer=${isMakingOffer}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
makingOfferRef.current.set(peerIdHex, true);
|
||||
console.log(`[WebRTC] Negotiation needed for ${peerIdHex}, creating offer...`);
|
||||
await pc.setLocalDescription();
|
||||
console.log(`[WebRTC] Sending offer to ${peerIdHex}`);
|
||||
sendSdpOffer({
|
||||
receiver: Identity.fromString(peerIdHex),
|
||||
sdp: JSON.stringify(pc.localDescription),
|
||||
channelId
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[WebRTC] Error during negotiation for ${peerIdHex}`, e);
|
||||
} finally {
|
||||
makingOfferRef.current.set(peerIdHex, false);
|
||||
}
|
||||
}, [sendSdpOffer]);
|
||||
|
||||
|
||||
const onIceCandidate = useCallback((peerIdHex: string, candidate: RTCIceCandidate) => {
|
||||
const channelId = connectedChannelIdRef.current;
|
||||
if (channelId) {
|
||||
sendIceCandidate({
|
||||
receiver: Identity.fromString(peerIdHex),
|
||||
candidate: JSON.stringify(candidate),
|
||||
channelId
|
||||
});
|
||||
}
|
||||
}, [sendIceCandidate]);
|
||||
|
||||
const {
|
||||
peers,
|
||||
peerStatuses,
|
||||
peerStats,
|
||||
createPeerConnection,
|
||||
closePeer,
|
||||
getPeer,
|
||||
peersRef
|
||||
} = usePeerManager(
|
||||
identity,
|
||||
isDeafened,
|
||||
localStreamRef,
|
||||
localScreenStreamRef,
|
||||
onNegotiationNeeded,
|
||||
onIceCandidate
|
||||
);
|
||||
|
||||
const {
|
||||
clearSignalingState
|
||||
} = useSignaling(
|
||||
identity,
|
||||
connectedChannelId,
|
||||
createPeerConnection,
|
||||
getPeer,
|
||||
makingOfferRef,
|
||||
ignoreOfferRef
|
||||
);
|
||||
|
||||
// Sync local media to existing peers
|
||||
useEffect(() => {
|
||||
const audioTrack = localStream?.getAudioTracks()[0] || null;
|
||||
peersRef.current.forEach(async (peer, peerIdHex) => {
|
||||
const transceivers = peer.pc.getTransceivers();
|
||||
if (transceivers[0] && transceivers[0].sender.track !== audioTrack) {
|
||||
console.log(`[WebRTC] Syncing audio track to peer ${peerIdHex}`);
|
||||
try {
|
||||
await transceivers[0].sender.replaceTrack(audioTrack);
|
||||
if (peer.pc.signalingState === 'stable') {
|
||||
onNegotiationNeeded(peerIdHex, peer.pc);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[WebRTC] Error replacing audio track for ${peerIdHex}`, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [localStream, peers, onNegotiationNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
const videoTrack = localScreenStream?.getVideoTracks()[0] || null;
|
||||
peersRef.current.forEach(async (peer, peerIdHex) => {
|
||||
const transceivers = peer.pc.getTransceivers();
|
||||
if (transceivers[1] && transceivers[1].sender.track !== videoTrack) {
|
||||
console.log(`[WebRTC] Syncing video track to peer ${peerIdHex}`);
|
||||
try {
|
||||
await transceivers[1].sender.replaceTrack(videoTrack);
|
||||
if (peer.pc.signalingState === 'stable') {
|
||||
onNegotiationNeeded(peerIdHex, peer.pc);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[WebRTC] Error replacing video track for ${peerIdHex}`, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [localScreenStream, peers, onNegotiationNeeded]);
|
||||
|
||||
|
||||
|
||||
// Determine who to connect to
|
||||
const peersToConnect = useMemo(() => {
|
||||
if (!identity || !connectedChannelId) return new Set<string>();
|
||||
const peerIds = new Set<string>();
|
||||
voiceStates.forEach(vs => {
|
||||
if (vs.channelId === connectedChannelId && !vs.identity.isEqual(identity)) {
|
||||
peerIds.add(vs.identity.toHexString());
|
||||
}
|
||||
});
|
||||
watching.forEach(w => {
|
||||
if (w.watcher.isEqual(identity)) {
|
||||
peerIds.add(w.watchee.toHexString());
|
||||
} else if (w.watchee.isEqual(identity)) {
|
||||
peerIds.add(w.watcher.toHexString());
|
||||
}
|
||||
});
|
||||
return peerIds;
|
||||
}, [voiceStates, watching, identity, connectedChannelId]);
|
||||
|
||||
// Peer Lifecycle Orchestration
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) {
|
||||
// Cleanup all
|
||||
if (peersRef.current.size > 0) {
|
||||
console.log("[WebRTC] Cleaning up all peer connections");
|
||||
peersRef.current.forEach((_, peerIdHex) => closePeer(peerIdHex));
|
||||
}
|
||||
releaseMic();
|
||||
clearSignalingState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Always clear signaling state when connectedChannelId changes to avoid stale row processing
|
||||
clearSignalingState();
|
||||
|
||||
// Connect to new peers
|
||||
peersToConnect.forEach(peerIdHex => {
|
||||
if (!peersRef.current.has(peerIdHex)) {
|
||||
createPeerConnection(peerIdHex);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup disconnected peers
|
||||
peersRef.current.forEach((_, peerIdHex) => {
|
||||
if (!peersToConnect.has(peerIdHex)) {
|
||||
closePeer(peerIdHex);
|
||||
}
|
||||
});
|
||||
|
||||
requestMic();
|
||||
}, [peersToConnect, connectedChannelId, identity, createPeerConnection, closePeer, requestMic, releaseMic, clearSignalingState]);
|
||||
|
||||
// Screen Share Actions
|
||||
const startScreenShare = useCallback(() => {
|
||||
startLocalScreenShare((track) => {
|
||||
// Handled by localScreenStream effect
|
||||
});
|
||||
}, [startLocalScreenShare]);
|
||||
|
||||
const stopScreenShare = useCallback(() => {
|
||||
stopLocalScreenShare((track) => {
|
||||
// Handled by localScreenStream effect
|
||||
});
|
||||
}, [stopLocalScreenShare]);
|
||||
|
||||
const startWatching = useCallback((peerIdentity: Identity) => {
|
||||
if (connectedChannelId) {
|
||||
startWatchingReducer({ watchee: peerIdentity, channelId: connectedChannelId });
|
||||
}
|
||||
}, [connectedChannelId, startWatchingReducer]);
|
||||
|
||||
const stopWatching = useCallback((peerIdentity: Identity) => {
|
||||
stopWatchingReducer({ watchee: peerIdentity });
|
||||
}, [stopWatchingReducer]);
|
||||
|
||||
return {
|
||||
localStream,
|
||||
localScreenStream,
|
||||
peerStatuses,
|
||||
peers,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
isSharingScreen,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
watching,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
peerStats
|
||||
};
|
||||
};
|
||||
|
||||
export default useWebRTC;
|
||||
|
||||
@@ -1,526 +0,0 @@
|
||||
import { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react";
|
||||
import { tables, reducers } from "./module_bindings";
|
||||
import * as Types from "./module_bindings/types";
|
||||
|
||||
const ICE_SERVERS: RTCConfiguration = {
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
};
|
||||
|
||||
interface Peer {
|
||||
pc: RTCPeerConnection;
|
||||
audio?: HTMLAudioElement;
|
||||
videoStream?: MediaStream;
|
||||
}
|
||||
|
||||
export interface WebRTCStats {
|
||||
audio: {
|
||||
bytesReceived: number;
|
||||
jitter: number;
|
||||
packetsLost: number;
|
||||
bitrate: number;
|
||||
};
|
||||
video: {
|
||||
bytesReceived: number;
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
framesPerSecond: number;
|
||||
bitrate: number;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
const { identity } = useSpacetimeDB();
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [localScreenStream, setLocalScreenStream] = useState<MediaStream | null>(null);
|
||||
const [peers, setPeers] = useState<Map<string, Peer>>(new Map());
|
||||
const [peerStatuses, setPeerStatuses] = useState<Map<string, string>>(new Map());
|
||||
const [peerStats, setPeerStats] = useState<Map<string, WebRTCStats>>(new Map());
|
||||
const peerStatsRef = useRef<Map<string, WebRTCStats>>(new Map());
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDeafened, setIsDeafened] = useState(false);
|
||||
|
||||
const sendSdpOffer = useReducer(reducers.sendSdpOffer);
|
||||
const sendSdpAnswer = useReducer(reducers.sendSdpAnswer);
|
||||
const sendIceCandidate = useReducer(reducers.sendIceCandidate);
|
||||
const setTalking = useReducer(reducers.setTalking);
|
||||
const setSharingScreen = useReducer(reducers.setSharingScreen);
|
||||
const startWatchingReducer = useReducer(reducers.startWatching);
|
||||
const stopWatchingReducer = useReducer(reducers.stopWatching);
|
||||
|
||||
const [offers] = useTable(tables.sdp_offer);
|
||||
const [answers] = useTable(tables.sdp_answer);
|
||||
const [iceCandidates] = useTable(tables.ice_candidate);
|
||||
const [voiceStates] = useTable(tables.voice_state);
|
||||
const [watching] = useTable(tables.watching);
|
||||
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const localScreenStreamRef = useRef<MediaStream | null>(null);
|
||||
const peersRef = useRef<Map<string, Peer>>(new Map());
|
||||
const isTalkingRef = useRef<boolean>(false);
|
||||
|
||||
// Mute/Deafen Logic
|
||||
useEffect(() => {
|
||||
if (localStream) {
|
||||
localStream.getAudioTracks().forEach(track => {
|
||||
track.enabled = !isMuted && !isDeafened;
|
||||
});
|
||||
}
|
||||
}, [isMuted, isDeafened, localStream]);
|
||||
|
||||
useEffect(() => {
|
||||
peers.forEach(peer => {
|
||||
if (peer.audio) {
|
||||
peer.audio.muted = isDeafened;
|
||||
}
|
||||
});
|
||||
}, [isDeafened, peers]);
|
||||
|
||||
// Stats Polling
|
||||
useEffect(() => {
|
||||
if (peers.size === 0) {
|
||||
if (peerStatsRef.current.size > 0) {
|
||||
peerStatsRef.current = new Map();
|
||||
setPeerStats(new Map());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const newStats = new Map(peerStatsRef.current);
|
||||
|
||||
for (const [peerIdHex, peer] of peers.entries()) {
|
||||
try {
|
||||
const stats = await peer.pc.getStats();
|
||||
const prevStats = peerStatsRef.current.get(peerIdHex);
|
||||
const currentStats: WebRTCStats = {
|
||||
audio: { bytesReceived: 0, jitter: 0, packetsLost: 0, bitrate: 0 },
|
||||
video: { bytesReceived: 0, frameWidth: 0, frameHeight: 0, framesPerSecond: 0, bitrate: 0 },
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'inbound-rtp') {
|
||||
if (report.kind === 'audio') {
|
||||
currentStats.audio.bytesReceived = report.bytesReceived || 0;
|
||||
currentStats.audio.jitter = report.jitter || 0;
|
||||
currentStats.audio.packetsLost = report.packetsLost || 0;
|
||||
|
||||
if (prevStats) {
|
||||
const deltaBytes = currentStats.audio.bytesReceived - prevStats.audio.bytesReceived;
|
||||
const deltaTime = (currentStats.timestamp - prevStats.timestamp) / 1000;
|
||||
if (deltaTime > 0) {
|
||||
currentStats.audio.bitrate = Math.max(0, deltaBytes * 8 / deltaTime);
|
||||
}
|
||||
}
|
||||
} else if (report.kind === 'video') {
|
||||
currentStats.video.bytesReceived = report.bytesReceived || 0;
|
||||
currentStats.video.frameWidth = report.frameWidth || 0;
|
||||
currentStats.video.frameHeight = report.frameHeight || 0;
|
||||
currentStats.video.framesPerSecond = report.framesPerSecond || 0;
|
||||
|
||||
if (prevStats) {
|
||||
const deltaBytes = currentStats.video.bytesReceived - prevStats.video.bytesReceived;
|
||||
const deltaTime = (currentStats.timestamp - prevStats.timestamp) / 1000;
|
||||
if (deltaTime > 0) {
|
||||
currentStats.video.bitrate = Math.max(0, deltaBytes * 8 / deltaTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
newStats.set(peerIdHex, currentStats);
|
||||
} catch (e) {
|
||||
console.warn(`[WebRTC] Failed to get stats for ${peerIdHex}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
peerStatsRef.current = newStats;
|
||||
setPeerStats(newStats);
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [peers]);
|
||||
|
||||
const toggleMute = useCallback(() => setIsMuted(prev => !prev), []);
|
||||
const toggleDeafen = useCallback(() => setIsDeafened(prev => !prev), []);
|
||||
|
||||
const closePeer = useCallback((peerIdHex: string) => {
|
||||
const peer = peersRef.current.get(peerIdHex);
|
||||
if (peer) {
|
||||
peer.pc.close();
|
||||
if (peer.audio) {
|
||||
peer.audio.pause();
|
||||
peer.audio.srcObject = null;
|
||||
}
|
||||
peersRef.current.delete(peerIdHex);
|
||||
setPeers(new Map(peersRef.current));
|
||||
setPeerStatuses(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(peerIdHex);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startWatching = useCallback((peerIdentity: Identity) => {
|
||||
if (connectedChannelId) {
|
||||
startWatchingReducer({ watchee: peerIdentity, channelId: connectedChannelId });
|
||||
}
|
||||
}, [connectedChannelId, startWatchingReducer]);
|
||||
|
||||
const stopWatching = useCallback((peerIdentity: Identity) => {
|
||||
stopWatchingReducer({ watchee: peerIdentity });
|
||||
}, [stopWatchingReducer]);
|
||||
|
||||
const startScreenShare = useCallback(async () => {
|
||||
console.log("[WebRTC] startScreenShare called");
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||
console.log("[WebRTC] getDisplayMedia success");
|
||||
setLocalScreenStream(stream);
|
||||
localScreenStreamRef.current = stream;
|
||||
setSharingScreen({ sharing: true });
|
||||
|
||||
const tracks = stream.getTracks();
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
console.log(`[WebRTC] Sharing screen tracks: ${tracks.map(t => t.kind).join(', ')}`);
|
||||
|
||||
const peers = Array.from(peersRef.current.entries());
|
||||
console.log(`[WebRTC] Sending screen tracks to ${peers.length} peers`);
|
||||
|
||||
for (const [peerIdHex, peer] of peers) {
|
||||
// Remove any old video tracks before adding the new screen video track
|
||||
peer.pc.getSenders().forEach(sender => {
|
||||
if (sender.track?.kind === 'video') {
|
||||
console.log(`[WebRTC] Removing old video track sender for ${peerIdHex}`);
|
||||
peer.pc.removeTrack(sender);
|
||||
}
|
||||
});
|
||||
|
||||
tracks.forEach(track => {
|
||||
console.log(`[WebRTC] Adding screen ${track.kind} track to PC for ${peerIdHex}`);
|
||||
peer.pc.addTrack(track, stream);
|
||||
});
|
||||
|
||||
const offer = await peer.pc.createOffer();
|
||||
await peer.pc.setLocalDescription(offer);
|
||||
console.log(`[WebRTC] Sent new offer with screen tracks to ${peerIdHex}`);
|
||||
sendSdpOffer({
|
||||
receiver: Identity.fromString(peerIdHex),
|
||||
sdp: JSON.stringify(offer),
|
||||
channelId: connectedChannelId!
|
||||
});
|
||||
}
|
||||
|
||||
if (videoTrack) {
|
||||
videoTrack.onended = () => {
|
||||
console.log("[WebRTC] Screen share track ended");
|
||||
stopScreenShare();
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[WebRTC] Failed to start screen share:", err);
|
||||
}
|
||||
}, [connectedChannelId, sendSdpOffer, setSharingScreen]);
|
||||
|
||||
const stopScreenShare = useCallback(() => {
|
||||
if (localScreenStreamRef.current) {
|
||||
const screenTracks = localScreenStreamRef.current.getTracks();
|
||||
screenTracks.forEach(track => track.stop());
|
||||
setLocalScreenStream(null);
|
||||
localScreenStreamRef.current = null;
|
||||
setSharingScreen({ sharing: false });
|
||||
|
||||
peersRef.current.forEach(async (peer, peerIdHex) => {
|
||||
peer.pc.getSenders().forEach(sender => {
|
||||
if (sender.track && screenTracks.includes(sender.track)) {
|
||||
console.log(`[WebRTC] Removing screen ${sender.track.kind} track from ${peerIdHex}`);
|
||||
peer.pc.removeTrack(sender);
|
||||
}
|
||||
});
|
||||
const offer = await peer.pc.createOffer();
|
||||
await peer.pc.setLocalDescription(offer);
|
||||
sendSdpOffer({
|
||||
receiver: Identity.fromString(peerIdHex),
|
||||
sdp: JSON.stringify(offer),
|
||||
channelId: connectedChannelId!
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [setSharingScreen, sendSdpOffer, connectedChannelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const currentPeers = Array.from(peersRef.current.keys());
|
||||
currentPeers.forEach(peerIdHex => {
|
||||
const isStillInChannel = voiceStates.some(vs =>
|
||||
vs.channelId === connectedChannelId && vs.identity.toHexString() === peerIdHex
|
||||
);
|
||||
if (!isStillInChannel) {
|
||||
console.log(`[WebRTC] Peer ${peerIdHex} left channel, closing connection`);
|
||||
closePeer(peerIdHex);
|
||||
}
|
||||
});
|
||||
}, [voiceStates, connectedChannelId, identity, closePeer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStream) {
|
||||
if (isTalkingRef.current) {
|
||||
setTalking({ talking: false });
|
||||
isTalkingRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const audioContext = new AudioContext();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
const source = audioContext.createMediaStreamSource(localStream);
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
const threshold = 15;
|
||||
let silenceFrames = 0;
|
||||
const maxSilenceFrames = 10;
|
||||
const checkAudio = setInterval(() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
|
||||
const average = sum / dataArray.length;
|
||||
if (average > threshold) {
|
||||
silenceFrames = 0;
|
||||
if (!isTalkingRef.current) {
|
||||
setTalking({ talking: true });
|
||||
isTalkingRef.current = true;
|
||||
}
|
||||
} else {
|
||||
silenceFrames++;
|
||||
if (silenceFrames > maxSilenceFrames && isTalkingRef.current) {
|
||||
setTalking({ talking: false });
|
||||
isTalkingRef.current = false;
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
return () => {
|
||||
clearInterval(checkAudio);
|
||||
audioContext.close();
|
||||
};
|
||||
}, [localStream, setTalking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectedChannelId) {
|
||||
if (!localStream) {
|
||||
console.log("[WebRTC] Joining voice channel, requesting mic permission...");
|
||||
navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
.then(stream => {
|
||||
console.log("[WebRTC] Mic permission granted");
|
||||
setLocalStream(stream);
|
||||
localStreamRef.current = stream;
|
||||
})
|
||||
.catch(err => console.error("[WebRTC] Failed to get mic:", err));
|
||||
}
|
||||
} else {
|
||||
if (localStream) {
|
||||
console.log("[WebRTC] Leaving voice channel, stopping tracks");
|
||||
localStream.getTracks().forEach(track => track.stop());
|
||||
setLocalStream(null);
|
||||
localStreamRef.current = null;
|
||||
}
|
||||
if (localScreenStreamRef.current) {
|
||||
localScreenStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
setLocalScreenStream(null);
|
||||
localScreenStreamRef.current = null;
|
||||
}
|
||||
peersRef.current.forEach(peer => {
|
||||
peer.pc.close();
|
||||
if (peer.audio) {
|
||||
peer.audio.pause();
|
||||
peer.audio.srcObject = null;
|
||||
}
|
||||
});
|
||||
peersRef.current.clear();
|
||||
setPeers(new Map());
|
||||
setPeerStatuses(new Map());
|
||||
}
|
||||
}, [connectedChannelId]);
|
||||
|
||||
const createPeerConnection = useCallback((peerIdentity: Identity) => {
|
||||
const peerIdHex = peerIdentity.toHexString();
|
||||
if (peersRef.current.has(peerIdHex)) return peersRef.current.get(peerIdHex)!.pc;
|
||||
|
||||
console.log(`[WebRTC] Creating new PeerConnection for ${peerIdHex}`);
|
||||
const pc = new RTCPeerConnection(ICE_SERVERS);
|
||||
|
||||
pc.onnegotiationneeded = async () => {
|
||||
try {
|
||||
if (identity && identity.toHexString() > peerIdHex) {
|
||||
console.log(`[WebRTC] Negotiation needed for ${peerIdHex}, sending offer`);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
sendSdpOffer({
|
||||
receiver: peerIdentity,
|
||||
sdp: JSON.stringify(offer),
|
||||
channelId: connectedChannelId!
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[WebRTC] Error during negotiation for ${peerIdHex}`, e);
|
||||
}
|
||||
};
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && connectedChannelId) {
|
||||
sendIceCandidate({ receiver: peerIdentity, candidate: JSON.stringify(event.candidate), channelId: connectedChannelId });
|
||||
}
|
||||
};
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
console.log(`[WebRTC] ICE state for ${peerIdHex}: ${pc.iceConnectionState}`);
|
||||
setPeerStatuses(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(peerIdHex, pc.iceConnectionState);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
pc.ontrack = (event) => {
|
||||
console.log(`[WebRTC] Received track from ${peerIdHex}: ${event.track.kind} (Streams: ${event.streams.length})`);
|
||||
const stream = event.streams[0] || new MediaStream([event.track]);
|
||||
|
||||
setPeers(prev => {
|
||||
const next = new Map(prev);
|
||||
const existingPeer = next.get(peerIdHex) || { pc };
|
||||
|
||||
if (event.track.kind === 'audio') {
|
||||
if (!existingPeer.audio) {
|
||||
existingPeer.audio = new Audio();
|
||||
existingPeer.audio.autoplay = true;
|
||||
}
|
||||
existingPeer.audio.srcObject = stream;
|
||||
existingPeer.audio.muted = isDeafened;
|
||||
existingPeer.audio.play().catch(e => console.error("[WebRTC] Error playing audio", e));
|
||||
} else if (event.track.kind === 'video') {
|
||||
console.log(`[WebRTC] Setting videoStream for ${peerIdHex}`);
|
||||
existingPeer.videoStream = stream;
|
||||
}
|
||||
next.set(peerIdHex, existingPeer);
|
||||
peersRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Add existing tracks
|
||||
if (localStreamRef.current) {
|
||||
console.log(`[WebRTC] Adding local audio tracks to new PC for ${peerIdHex}`);
|
||||
localStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localStreamRef.current!));
|
||||
}
|
||||
if (localScreenStreamRef.current) {
|
||||
console.log(`[WebRTC] Adding local screen tracks to new PC for ${peerIdHex}`);
|
||||
localScreenStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localScreenStreamRef.current!));
|
||||
}
|
||||
|
||||
peersRef.current.set(peerIdHex, { pc });
|
||||
setPeers(new Map(peersRef.current));
|
||||
return pc;
|
||||
}, [connectedChannelId, sendIceCandidate, isDeafened, identity, sendSdpOffer]);
|
||||
|
||||
// Handle localStream changes (mic)
|
||||
useEffect(() => {
|
||||
if (localStream) {
|
||||
const audioTrack = localStream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
peersRef.current.forEach((peer, peerIdHex) => {
|
||||
const alreadyHasTrack = peer.pc.getSenders().some(s => s.track === audioTrack);
|
||||
if (!alreadyHasTrack) {
|
||||
console.log(`[WebRTC] Adding audio track to existing PC for ${peerIdHex}`);
|
||||
peer.pc.addTrack(audioTrack, localStream);
|
||||
// onnegotiationneeded will fire if we are the initiator
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [localStream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const channelPeers = voiceStates.filter(vs => vs.channelId === connectedChannelId && !vs.identity.isEqual(identity));
|
||||
channelPeers.forEach(async (peerVs) => {
|
||||
const peerIdHex = peerVs.identity.toHexString();
|
||||
if (!peersRef.current.has(peerIdHex)) {
|
||||
if (identity.toHexString() > peerIdHex) {
|
||||
console.log(`[WebRTC] Initiating offer to ${peerIdHex}`);
|
||||
const pc = createPeerConnection(peerVs.identity);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
sendSdpOffer({ receiver: peerVs.identity, sdp: JSON.stringify(offer), channelId: connectedChannelId });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [voiceStates, connectedChannelId, identity, createPeerConnection, sendSdpOffer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const myOffers = offers.filter(o => o.receiver.isEqual(identity) && o.channelId === connectedChannelId);
|
||||
myOffers.forEach(async (offerRow) => {
|
||||
const pc = createPeerConnection(offerRow.sender);
|
||||
if (pc.signalingState === "stable" || pc.signalingState === "have-local-offer") {
|
||||
try {
|
||||
console.log(`[WebRTC] Handling offer from ${offerRow.sender.toHexString()}`);
|
||||
const offer = JSON.parse(offerRow.sdp);
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId });
|
||||
} catch (e) { console.error("[WebRTC] Error handling offer", e); }
|
||||
}
|
||||
});
|
||||
}, [offers, connectedChannelId, identity, createPeerConnection, sendSdpAnswer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const myAnswers = answers.filter(a => a.receiver.isEqual(identity) && a.channelId === connectedChannelId);
|
||||
myAnswers.forEach(async (answerRow) => {
|
||||
const peer = peersRef.current.get(answerRow.sender.toHexString());
|
||||
if (peer && peer.pc.signalingState === "have-local-offer") {
|
||||
try {
|
||||
const answer = JSON.parse(answerRow.sdp);
|
||||
await peer.pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
} catch (e) { console.error("[WebRTC] Error handling answer", e); }
|
||||
}
|
||||
});
|
||||
}, [answers, connectedChannelId, identity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const myCandidates = iceCandidates.filter(c => c.receiver.isEqual(identity) && c.channelId === connectedChannelId);
|
||||
myCandidates.forEach(async (candRow) => {
|
||||
const peer = peersRef.current.get(candRow.sender.toHexString());
|
||||
if (peer && peer.pc.remoteDescription) {
|
||||
try {
|
||||
const candidate = JSON.parse(candRow.candidate);
|
||||
await peer.pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} catch (e) { console.error("[WebRTC] Error handling ICE", e); }
|
||||
}
|
||||
});
|
||||
}, [iceCandidates, connectedChannelId, identity]);
|
||||
|
||||
return {
|
||||
localStream,
|
||||
localScreenStream,
|
||||
peerStatuses,
|
||||
peers,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
isSharingScreen: !!localScreenStream,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
watching,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
peerStats
|
||||
};
|
||||
};
|
||||
|
||||
export default useWebRTC;
|
||||
Reference in New Issue
Block a user