peerstats
This commit is contained in:
@@ -545,6 +545,27 @@ export const onConnect = spacetimedb.clientConnected(ctx => {
|
||||
}
|
||||
} else if (user) {
|
||||
ctx.db.user.identity.update({ ...user, online: true, talking: false });
|
||||
} else {
|
||||
// New anonymous user
|
||||
ctx.db.user.insert({
|
||||
name: undefined,
|
||||
identity: ctx.sender,
|
||||
online: true,
|
||||
talking: false,
|
||||
issuer: undefined,
|
||||
subject: undefined,
|
||||
username: undefined,
|
||||
password: undefined
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-join the "Spacetime Community" server if it exists
|
||||
const communityServer = [...ctx.db.server.iter()].find(s => s.name === 'Spacetime Community');
|
||||
if (communityServer) {
|
||||
const alreadyMember = [...ctx.db.server_member.by_identity.filter(ctx.sender)].some(m => m.server_id === communityServer.id);
|
||||
if (!alreadyMember) {
|
||||
ctx.db.server_member.insert({ id: 0n, identity: ctx.sender, server_id: communityServer.id });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+86
-1
@@ -616,12 +616,97 @@ body {
|
||||
.status-dot.red {
|
||||
background-color: #f23f43;
|
||||
}
|
||||
|
||||
.status-dot.grey {
|
||||
background-color: #80848e;
|
||||
}
|
||||
|
||||
/* Connection Popover */
|
||||
.connection-popover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
margin-left: 12px;
|
||||
width: 220px;
|
||||
background-color: var(--background-floating, #1e1f22);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.24);
|
||||
z-index: 2000;
|
||||
color: var(--text-normal, #dbdee1);
|
||||
font-family: 'gg sans', sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.connection-popover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--background-floating);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.popover-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.popover-status {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.popover-status.green { color: #23a559; }
|
||||
.popover-status.yellow { color: #f0b232; }
|
||||
.popover-status.red { color: #f23f43; }
|
||||
|
||||
.popover-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stats-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.stat-row span:last-child {
|
||||
color: var(--header-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
...
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -32,7 +32,8 @@ const ChatContainer: React.FC = () => {
|
||||
isMuted,
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen
|
||||
toggleDeafen,
|
||||
peerStats
|
||||
} = useWebRTC(chat.connectedVoiceChannel?.id);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,6 +83,7 @@ const ChatContainer: React.FC = () => {
|
||||
handleLeaveVoice={chat.handleLeaveVoice}
|
||||
peerStatuses={peerStatuses}
|
||||
watching={watching}
|
||||
peerStats={peerStats}
|
||||
/>
|
||||
|
||||
{/* Voice Connected Status Bar */}
|
||||
@@ -222,15 +224,14 @@ const ChatContainer: React.FC = () => {
|
||||
) : (
|
||||
showMemberList && (
|
||||
<MemberList
|
||||
onlineUsers={chat.onlineUsers}
|
||||
activeServerMembers={chat.activeServerMembers}
|
||||
users={chat.users}
|
||||
identity={identity ?? null}
|
||||
activeServer={chat.activeServer}
|
||||
voiceStates={chat.voiceStates}
|
||||
currentVoiceState={chat.currentVoiceState}
|
||||
connectedVoiceChannel={chat.connectedVoiceChannel}
|
||||
/>
|
||||
)
|
||||
/> )
|
||||
)}
|
||||
|
||||
{chat.showDiscoveryModal && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import * as Types from "../../module_bindings/types";
|
||||
import { WebRTCStats } from "../../useWebRTC";
|
||||
|
||||
interface ChannelListProps {
|
||||
activeServerId: bigint | null;
|
||||
@@ -28,6 +29,7 @@ interface ChannelListProps {
|
||||
handleLeaveVoice: () => void;
|
||||
peerStatuses: Map<string, string>;
|
||||
watching: readonly Types.Watching[];
|
||||
peerStats: Map<string, WebRTCStats>;
|
||||
}
|
||||
|
||||
// Helper function (extracted from App.tsx)
|
||||
@@ -43,12 +45,53 @@ const getStatusColor = (status: string | undefined): "green" | "yellow" | "red"
|
||||
return 'red';
|
||||
};
|
||||
|
||||
const formatBitrate = (bps: number) => {
|
||||
if (bps > 1000000) return `${(bps / 1000000).toFixed(2)} Mbps`;
|
||||
if (bps > 1000) return `${(bps / 1000).toFixed(1)} Kbps`;
|
||||
return `${bps.toFixed(0)} bps`;
|
||||
};
|
||||
|
||||
const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: string, isMe: boolean }> = ({ stats, status, name, isMe }) => {
|
||||
return (
|
||||
<div className="connection-popover">
|
||||
<div className="popover-header">
|
||||
<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 ? (
|
||||
<div className="popover-stats">
|
||||
<div className="stats-section">
|
||||
<div className="section-title">AUDIO</div>
|
||||
<div className="stat-row"><span>Bitrate</span><span>{formatBitrate(stats.audio.bitrate)}</span></div>
|
||||
<div className="stat-row"><span>Jitter</span><span>{(stats.audio.jitter * 1000).toFixed(2)} ms</span></div>
|
||||
<div className="stat-row"><span>Loss</span><span>{stats.audio.packetsLost} pkts</span></div>
|
||||
</div>
|
||||
{stats.video.bitrate > 0 && (
|
||||
<div className="stats-section">
|
||||
<div className="section-title">VIDEO</div>
|
||||
<div className="stat-row"><span>Bitrate</span><span>{formatBitrate(stats.video.bitrate)}</span></div>
|
||||
<div className="stat-row"><span>Res</span><span>{stats.video.frameWidth}x{stats.video.frameHeight}</span></div>
|
||||
<div className="stat-row"><span>FPS</span><span>{stats.video.framesPerSecond.toFixed(0)}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="popover-info">Waiting for statistics...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
activeServerId, activeChannelId, setActiveChannelId, setActiveThreadId,
|
||||
channels, servers, users, identity, voiceStates, currentVoiceState, connectedVoiceChannel, isFullyAuthenticated,
|
||||
showCreateChannelModal, setShowCreateChannelModal, newChannelName, setNewChannelName, isVoiceChannel, setIsVoiceChannel,
|
||||
handleCreateChannel, handleJoinVoice, handleLeaveVoice, peerStatuses, watching
|
||||
handleCreateChannel, handleJoinVoice, handleLeaveVoice, peerStatuses, watching, peerStats
|
||||
}) => {
|
||||
const [hoveredPeer, setHoveredPeer] = React.useState<string | null>(null);
|
||||
const activeServer = React.useMemo(() =>
|
||||
servers.find(s => s.id === activeServerId),
|
||||
[servers, activeServerId]
|
||||
@@ -137,10 +180,22 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
if (videoStatusColor === 'red') finalStatusColor = 'red';
|
||||
else if (videoStatusColor === 'yellow' && finalStatusColor === 'green') finalStatusColor = 'yellow';
|
||||
|
||||
const tooltip = `Voice: ${isMe ? 'Connected' : (status || 'Connecting...')}${isSharing ? ` | Video: ${isMe ? 'Sharing' : (status || 'Connecting...')}` : ''}`;
|
||||
|
||||
return (
|
||||
<div key={peerIdHex} style={{display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.85rem', color: 'var(--text-muted)', height: '20px'}}>
|
||||
<div
|
||||
key={peerIdHex}
|
||||
className="voice-member-item"
|
||||
style={{
|
||||
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"
|
||||
style={{
|
||||
@@ -178,10 +233,20 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
padding: '0 4px',
|
||||
cursor: 'help'
|
||||
}}
|
||||
title={tooltip}
|
||||
onMouseEnter={() => setHoveredPeer(peerIdHex)}
|
||||
onMouseLeave={() => setHoveredPeer(null)}
|
||||
>
|
||||
<div className={`status-dot ${finalStatusColor}`} />
|
||||
</div>
|
||||
|
||||
{hoveredPeer === peerIdHex && (
|
||||
<ConnectionPopover
|
||||
stats={peerStats.get(peerIdHex)}
|
||||
status={isMe ? 'connected' : (status || 'connecting')}
|
||||
name={getUsername(vs.identity, users)}
|
||||
isMe={isMe}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import type * as Types from '../../module_bindings/types';
|
||||
import { tables } from '../../module_bindings';
|
||||
|
||||
// Helper function (extracted from App.tsx)
|
||||
const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]) => {
|
||||
@@ -11,7 +12,7 @@ const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]
|
||||
};
|
||||
|
||||
interface MemberListProps {
|
||||
onlineUsers: readonly Types.User[];
|
||||
activeServerMembers: readonly Types.User[];
|
||||
users: readonly Types.User[];
|
||||
identity: Identity | null;
|
||||
activeServer: Types.Server | undefined;
|
||||
@@ -20,53 +21,79 @@ interface MemberListProps {
|
||||
connectedVoiceChannel: Types.Channel | undefined;
|
||||
}
|
||||
|
||||
function MemberList({ onlineUsers, users, identity, activeServer, voiceStates, currentVoiceState, connectedVoiceChannel }: MemberListProps) {
|
||||
function MemberList({ activeServerMembers, users, identity, activeServer, voiceStates, currentVoiceState, connectedVoiceChannel }: MemberListProps) {
|
||||
|
||||
// Categorize members into Online and Offline
|
||||
const onlineMembers = useMemo(() =>
|
||||
activeServerMembers.filter(m => m.online),
|
||||
[activeServerMembers]
|
||||
);
|
||||
const offlineMembers = useMemo(() =>
|
||||
activeServerMembers.filter(m => !m.online),
|
||||
[activeServerMembers]
|
||||
);
|
||||
|
||||
const renderMember = (user: Types.User, isOffline: boolean = false) => {
|
||||
const userVoiceState = voiceStates.find(vs => vs.identity.isEqual(user.identity));
|
||||
const isTalking = user.talking || false;
|
||||
const isSharing = userVoiceState?.isSharingScreen || false;
|
||||
const isMe = identity?.isEqual(user.identity);
|
||||
|
||||
return (
|
||||
<div key={user.identity.toHexString()} className="member-item" style={{ opacity: isOffline ? 0.5 : 1 }}>
|
||||
<div
|
||||
className="avatar small"
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
fontSize: '0.7rem',
|
||||
backgroundColor: 'var(--background-tertiary)',
|
||||
border: isTalking && !isOffline ? '2px solid #23a559' : '2px solid transparent',
|
||||
boxShadow: isTalking && !isOffline ? '0 0 4px #23a559' : 'none',
|
||||
transition: 'all 0.1s ease-in-out'
|
||||
}}
|
||||
>
|
||||
{(user.name || user.identity.toHexString()).substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="member-name" style={{ color: isTalking && !isOffline ? 'white' : 'inherit' }}>
|
||||
{user.name || user.identity.toHexString().substring(0, 8)}
|
||||
{isMe && <span style={{ color: 'var(--text-muted)', fontSize: '0.7rem', marginLeft: '4px' }}>(You)</span>}
|
||||
</span>
|
||||
{isSharing && !isOffline && (
|
||||
<span style={{
|
||||
backgroundColor: '#f23f43',
|
||||
color: 'white',
|
||||
fontSize: '0.6rem',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 'auto'
|
||||
}}>LIVE</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="right-sidebar">
|
||||
<div className="member-list">
|
||||
<div style={{padding: '0 8px 8px 8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>
|
||||
ONLINE — {onlineUsers.length}
|
||||
</div>
|
||||
{onlineUsers.map(user => {
|
||||
const userVoiceState = voiceStates.find(vs => vs.identity.isEqual(user.identity));
|
||||
const isTalking = user.talking || false;
|
||||
const isSharing = userVoiceState?.isSharingScreen || false;
|
||||
const isMe = identity?.isEqual(user.identity);
|
||||
|
||||
return (
|
||||
<div key={user.identity.toHexString()} className="member-item">
|
||||
<div
|
||||
className="avatar small"
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
fontSize: '0.7rem',
|
||||
backgroundColor: 'var(--background-tertiary)',
|
||||
border: isTalking ? '2px solid #23a559' : '2px solid transparent',
|
||||
boxShadow: isTalking ? '0 0 4px #23a559' : 'none',
|
||||
transition: 'all 0.1s ease-in-out'
|
||||
}}
|
||||
>
|
||||
{(user.name || user.identity.toHexString()).substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="member-name" style={{ color: isTalking ? 'white' : 'inherit' }}>
|
||||
{user.name || user.identity.toHexString().substring(0, 8)}
|
||||
{isMe && <span style={{ color: 'var(--text-muted)', fontSize: '0.7rem', marginLeft: '4px' }}>(You)</span>}
|
||||
</span>
|
||||
{isSharing && (
|
||||
<span style={{
|
||||
backgroundColor: '#f23f43',
|
||||
color: 'white',
|
||||
fontSize: '0.6rem',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 'auto'
|
||||
}}>LIVE</span>
|
||||
)}
|
||||
{onlineMembers.length > 0 && (
|
||||
<>
|
||||
<div style={{padding: '0 8px 8px 8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>
|
||||
ONLINE — {onlineMembers.length}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{onlineMembers.map(user => renderMember(user))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{offlineMembers.length > 0 && (
|
||||
<>
|
||||
<div style={{padding: '16px 8px 8px 8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>
|
||||
OFFLINE — {offlineMembers.length}
|
||||
</div>
|
||||
{offlineMembers.map(user => renderMember(user, true))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,6 +49,7 @@ interface ChatState {
|
||||
currentVoiceState: Types.VoiceState | undefined;
|
||||
connectedVoiceChannel: Types.Channel | undefined;
|
||||
onlineUsers: readonly Types.User[];
|
||||
activeServerMembers: readonly Types.User[];
|
||||
currentUser: Types.User | undefined;
|
||||
activeServer: Types.Server | undefined;
|
||||
activeChannel: Types.Channel | undefined;
|
||||
@@ -345,7 +346,13 @@ export function useChat(): ChatState {
|
||||
|
||||
// Data fetched from tables
|
||||
servers, joinedServers, availableServers, channels, users, allMessages, allThreads, voiceStates,
|
||||
currentVoiceState, connectedVoiceChannel, onlineUsers, currentUser,
|
||||
currentVoiceState, connectedVoiceChannel, onlineUsers,
|
||||
activeServerMembers: useMemo(() => {
|
||||
if (!activeServerId) return [];
|
||||
const memberIdentities = new Set(serverMembers.filter(m => m.serverId === activeServerId).map(m => m.identity.toHexString()));
|
||||
return users.filter(u => memberIdentities.has(u.identity.toHexString()));
|
||||
}, [serverMembers, users, activeServerId]),
|
||||
currentUser,
|
||||
activeServer, activeChannel, activeThread, isActiveChannelVoice, isActiveChannelText, channelMessages, threadMessages,
|
||||
textChannels, voiceChannels,
|
||||
|
||||
|
||||
+134
-10
@@ -14,12 +14,31 @@ interface Peer {
|
||||
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);
|
||||
|
||||
@@ -59,6 +78,73 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
});
|
||||
}, [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), []);
|
||||
|
||||
@@ -232,11 +318,6 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
console.log("[WebRTC] Mic permission granted");
|
||||
setLocalStream(stream);
|
||||
localStreamRef.current = stream;
|
||||
// Add track to existing peer connections if they exist
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
peersRef.current.forEach(peer => {
|
||||
peer.pc.addTrack(audioTrack, stream);
|
||||
});
|
||||
})
|
||||
.catch(err => console.error("[WebRTC] Failed to get mic:", err));
|
||||
}
|
||||
@@ -271,6 +352,24 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
|
||||
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 });
|
||||
@@ -305,18 +404,42 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
existingPeer.videoStream = stream;
|
||||
}
|
||||
next.set(peerIdHex, existingPeer);
|
||||
peersRef.current = next; // Sync the ref with the updated peer object
|
||||
peersRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localStreamRef.current!));
|
||||
if (localScreenStreamRef.current) localScreenStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localScreenStreamRef.current!));
|
||||
// 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]);
|
||||
}, [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;
|
||||
@@ -395,7 +518,8 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
isMuted,
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen
|
||||
toggleDeafen,
|
||||
peerStats
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user