peerstats

This commit is contained in:
2026-03-30 15:47:48 -04:00
parent 04ff317f1a
commit 60779627ed
7 changed files with 395 additions and 65 deletions
+21
View File
@@ -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
View File
@@ -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;
+5 -4
View File
@@ -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 && (
+70 -5
View File
@@ -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>
);
})}
+71 -44
View File
@@ -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>
);
+8 -1
View File
@@ -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
View File
@@ -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
};
};