voice channel audio working again

This commit is contained in:
2026-03-30 17:01:33 -04:00
parent 60779627ed
commit b9524fbfd4
11 changed files with 928 additions and 595 deletions
+10 -7
View File
@@ -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);
-1
View File
@@ -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
View File
@@ -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)}
+30 -30
View File
@@ -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)}
+2
View File
@@ -1 +1,3 @@
export { useChat } from "./useChat";
export { useWebRTC } from "./webrtc/useWebRTC";
export type { WebRTCStats } from "./webrtc/types";
+39
View File
@@ -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;
}
+145
View File
@@ -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
};
};
+239
View File
@@ -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
};
};
+188
View File
@@ -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
};
};
+246
View File
@@ -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;
-526
View File
@@ -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;