diff --git a/public/app.js b/public/app.js
index 1f508d9..7c7b010 100644
--- a/public/app.js
+++ b/public/app.js
@@ -27,6 +27,7 @@ const S = {
connecting: false,
pingTimer: null,
reconnect: { room: null, attempt: 0, timer: null, countdownTimer: null },
+ channelRosters: {}, // roomId -> [snapshot]
};
window.__dbg = S; // POC debug handle
@@ -116,6 +117,20 @@ async function fetchIce() {
return res.json();
}
+async function fetchRooms() {
+ try {
+ const headers = window.rum && typeof window.rum.traceHeaders === 'function' ? window.rum.traceHeaders() : {};
+ const res = await fetch('/api/rooms', { headers });
+ if (res.ok) {
+ S.channelRosters = await res.json();
+ renderTiles();
+ renderChannelCounts();
+ }
+ } catch (err) {
+ console.warn('fetchRooms failed', err);
+ }
+}
+
function leaveVoice(reason) {
if (!S.room && !S.ws) return;
if (S.sharing) stopScreenShare(true);
@@ -135,6 +150,7 @@ function leaveVoice(reason) {
updateDeafenButton();
renderAll();
setStatus(reason || 'not connected');
+ fetchRooms();
}
function setStatus(text, state = '') {
@@ -264,6 +280,12 @@ function send(msg) {
function handleSignal(msg) {
switch (msg.type) {
+ case 'rosters': {
+ S.channelRosters = msg.rooms || {};
+ renderTiles();
+ renderChannelCounts();
+ break;
+ }
case 'hello': {
S.myId = msg.id;
S.room = msg.room;
@@ -628,10 +650,17 @@ function renderStageEmpty() {
function renderChannelCounts() {
const counts = {};
- for (const p of S.peers.values()) counts[S.room] = (counts[S.room] || 0) + 1;
+ // Count users across all rooms known from snapshot
+ for (const [rId, users] of Object.entries(S.channelRosters || {})) {
+ counts[rId] = users ? users.length : 0;
+ }
+ // If active room has peers, ensure active count includes me + peers
+ if (S.room) {
+ counts[S.room] = S.peers.size + 1;
+ }
document.querySelectorAll('[data-room-count]').forEach((el) => {
const n = counts[el.dataset.roomCount] || 0;
- el.textContent = n ? `${n + (el.dataset.roomCount === S.room ? 1 : 0)}` : '';
+ el.textContent = n ? `${n}` : '';
});
}
@@ -639,87 +668,108 @@ function renderTiles() {
// Clear any existing user rosters in voice channels
document.querySelectorAll('.channel-users').forEach((el) => { el.innerHTML = ''; });
- if (!S.room) {
- renderStageEmpty();
- return;
- }
+ // For each voice channel element in the sidebar
+ document.querySelectorAll('.channel.voice').forEach((channelEl) => {
+ const roomId = channelEl.dataset.room;
+ if (!roomId) return;
- const activeChannelEl = document.querySelector(`.channel.voice[data-room="${S.room}"]`);
- if (!activeChannelEl) {
- renderStageEmpty();
- return;
- }
-
- let roster = activeChannelEl.nextElementSibling;
- if (!roster || !roster.classList.contains('channel-users')) {
- roster = document.createElement('div');
- roster.className = 'channel-users';
- activeChannelEl.after(roster);
- }
-
- // Render "Me" first
- const meItem = document.createElement('div');
- meItem.className = 'channel-user-item' + (els.meAvatar.classList.contains('speaking') ? ' speaking' : '');
- const meAv = document.createElement('div');
- meAv.className = 'avatar user-line-avatar';
- setAvatar(meAv, S.name, S.myId || 'me');
- const meName = document.createElement('span');
- meName.className = 'user-line-name';
- meName.textContent = `${S.name} (you)`;
-
- const meBadges = document.createElement('span');
- meBadges.className = 'user-line-badges';
- if (S.muted) meBadges.innerHTML += '🎤️❌';
- if (S.deafened) meBadges.innerHTML += '👂❌';
- if (S.sharing) meBadges.innerHTML += '🖥️';
-
- meItem.append(meAv, meName, meBadges);
- roster.appendChild(meItem);
-
- // Render Peers
- for (const peer of S.peers.values()) {
- const item = document.createElement('div');
- item.className = 'channel-user-item' + (peer.speaking ? ' speaking' : '');
-
- const av = document.createElement('div');
- av.className = 'avatar user-line-avatar';
- setAvatar(av, peer.name, peer.id);
-
- const name = document.createElement('span');
- name.className = 'user-line-name';
- name.textContent = peer.name;
-
- const badges = document.createElement('span');
- badges.className = 'user-line-badges';
- if (!peer.pc || peer.pc.connectionState !== 'connected') {
- badges.innerHTML += '…';
+ let roster = channelEl.nextElementSibling;
+ if (!roster || !roster.classList.contains('channel-users')) {
+ roster = document.createElement('div');
+ roster.className = 'channel-users';
+ channelEl.after(roster);
}
- if (peer.state.muted) badges.innerHTML += '🎤️❌';
- if (peer.state.sharing) badges.innerHTML += '🖥️';
- // Volume hover popup
- const volPop = document.createElement('div');
- volPop.className = 'volume-popup';
- const volLabel = document.createElement('div');
- volLabel.className = 'vol-title';
- volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`;
- const volInput = document.createElement('input');
- volInput.type = 'range';
- volInput.min = 0;
- volInput.max = 2;
- volInput.step = 0.05;
- volInput.value = peer.vol;
- volInput.oninput = (e) => {
- e.stopPropagation();
- peer.vol = Number(volInput.value);
- volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`;
- if (peer.gain && !S.deafened) peer.gain.gain.value = peer.vol;
- };
- volPop.append(volLabel, volInput);
+ if (S.room && S.room === roomId) {
+ // Current active room: render Me + live peers (with interactive volume sliders)
+ const meItem = document.createElement('div');
+ meItem.className = 'channel-user-item' + (els.meAvatar.classList.contains('speaking') ? ' speaking' : '');
+ const meAv = document.createElement('div');
+ meAv.className = 'avatar user-line-avatar';
+ setAvatar(meAv, S.name, S.myId || 'me');
+ const meName = document.createElement('span');
+ meName.className = 'user-line-name';
+ meName.textContent = `${S.name} (you)`;
- item.append(av, name, badges, volPop);
- roster.appendChild(item);
- }
+ const meBadges = document.createElement('span');
+ meBadges.className = 'user-line-badges';
+ if (S.muted) meBadges.innerHTML += '🎤️❌';
+ if (S.deafened) meBadges.innerHTML += '👂❌';
+ if (S.sharing) meBadges.innerHTML += '🖥️';
+
+ meItem.append(meAv, meName, meBadges);
+ roster.appendChild(meItem);
+
+ for (const peer of S.peers.values()) {
+ const item = document.createElement('div');
+ item.className = 'channel-user-item' + (peer.speaking ? ' speaking' : '');
+
+ const av = document.createElement('div');
+ av.className = 'avatar user-line-avatar';
+ setAvatar(av, peer.name, peer.id);
+
+ const name = document.createElement('span');
+ name.className = 'user-line-name';
+ name.textContent = peer.name;
+
+ const badges = document.createElement('span');
+ badges.className = 'user-line-badges';
+ if (!peer.pc || peer.pc.connectionState !== 'connected') {
+ badges.innerHTML += '…';
+ }
+ if (peer.state.muted) badges.innerHTML += '🎤️❌';
+ if (peer.state.sharing) badges.innerHTML += '🖥️';
+
+ // Volume hover popup
+ const volPop = document.createElement('div');
+ volPop.className = 'volume-popup';
+ const volLabel = document.createElement('div');
+ volLabel.className = 'vol-title';
+ volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`;
+ const volInput = document.createElement('input');
+ volInput.type = 'range';
+ volInput.min = 0;
+ volInput.max = 2;
+ volInput.step = 0.05;
+ volInput.value = peer.vol;
+ volInput.oninput = (e) => {
+ e.stopPropagation();
+ peer.vol = Number(volInput.value);
+ volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`;
+ if (peer.gain && !S.deafened) peer.gain.gain.value = peer.vol;
+ };
+ volPop.append(volLabel, volInput);
+
+ item.append(av, name, badges, volPop);
+ roster.appendChild(item);
+ }
+ } else {
+ // Remote / unjoined channel: render users from server roster snapshot
+ const occupants = S.channelRosters && S.channelRosters[roomId];
+ if (Array.isArray(occupants) && occupants.length > 0) {
+ for (const user of occupants) {
+ const item = document.createElement('div');
+ item.className = 'channel-user-item';
+
+ const av = document.createElement('div');
+ av.className = 'avatar user-line-avatar';
+ setAvatar(av, user.name, user.id);
+
+ const name = document.createElement('span');
+ name.className = 'user-line-name';
+ name.textContent = user.name;
+
+ const badges = document.createElement('span');
+ badges.className = 'user-line-badges';
+ if (user.state?.muted) badges.innerHTML += '🎤️❌';
+ if (user.state?.sharing) badges.innerHTML += '🖥️';
+
+ item.append(av, name, badges);
+ roster.appendChild(item);
+ }
+ }
+ }
+ });
renderStageEmpty();
}
@@ -1200,6 +1250,16 @@ document.querySelectorAll('.channel.voice').forEach((el) => {
});
};
});
+
+// Initial roster fetch and lightweight periodic sync (especially for unjoined clients)
+fetchRooms();
+setInterval(() => {
+ // If not connected to websocket, poll for changes across rooms
+ if (!S.ws || S.ws.readyState !== 1) {
+ fetchRooms();
+ }
+}, 5000);
+
// spawn me somewhere spread out, not dead-center on top of others
(() => {
let h = 0;
diff --git a/server.js b/server.js
index cc2d7b1..b3d05c5 100644
--- a/server.js
+++ b/server.js
@@ -102,6 +102,11 @@ const server = http.createServer((req, res) => {
res.end(JSON.stringify(iceConfig()));
return;
}
+ if (req.url.startsWith('/api/rooms')) {
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
+ res.end(JSON.stringify(allRoomsSnapshot()));
+ return;
+ }
serveStatic(req, res);
});
@@ -131,6 +136,24 @@ function broadcast(room, msg, exceptId) {
}
}
+function allRoomsSnapshot() {
+ const out = {};
+ for (const [rId, r] of rooms.entries()) {
+ out[rId] = [...r.values()].map(snapshot);
+ }
+ return out;
+}
+
+function broadcastRosters() {
+ const snap = allRoomsSnapshot();
+ const data = JSON.stringify({ type: 'rosters', rooms: snap });
+ for (const client of wss.clients) {
+ if (client.readyState === 1) {
+ client.send(data);
+ }
+ }
+}
+
wss.on('connection', (socket, req) => {
const q = new URL(req.url, 'http://x').searchParams;
const roomId = (q.get('room') || 'general').slice(0, 64);
@@ -146,6 +169,7 @@ wss.on('connection', (socket, req) => {
send({ type: 'hello', id, room: roomId, peers: [...room.values()].filter((p) => p.id !== id).map(snapshot) });
broadcast(room, { type: 'joined', peer: snapshot(client) }, id);
+ broadcastRosters();
console.log(`[signaling] ${name} (${id}) joined #${roomId} — ${room.size} in room`);
socket.on('message', (raw) => {
@@ -178,6 +202,7 @@ wss.on('connection', (socket, req) => {
sharing: !!msg.state?.sharing,
};
broadcast(room, { type: 'state', from: id, state: client.state }, id);
+ broadcastRosters();
break;
}
case 'offer':
@@ -200,6 +225,7 @@ wss.on('connection', (socket, req) => {
room.delete(id);
if (room.size === 0) rooms.delete(roomId);
else broadcast(room, { type: 'left', id });
+ broadcastRosters();
console.log(`[signaling] ${name} (${id}) left #${roomId} — ${room.size} in room`);
});
});
@@ -235,6 +261,6 @@ if (require.main === module) {
startServer();
}
-module.exports = { server, wss, iceConfig, interval, startServer };
+module.exports = { server, wss, iceConfig, allRoomsSnapshot, interval, startServer };
diff --git a/test.js b/test.js
index 71e1d69..ff05d8c 100644
--- a/test.js
+++ b/test.js
@@ -38,15 +38,23 @@ test('server HTTP and WebSocket signaling endpoints', async (t) => {
const healthText = await resHealth.text();
assert.strictEqual(healthText, 'ok');
+ // test /api/rooms initially empty
+ const resRoomsInit = await fetch(`http://127.0.0.1:${port}/api/rooms`);
+ assert.strictEqual(resRoomsInit.status, 200);
+ const roomsInit = await resRoomsInit.json();
+ assert.deepStrictEqual(roomsInit, {});
+
// test WebSocket signaling /ws
const { WebSocket } = require('ws');
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?room=testroom&name=tester`);
+ const messages = [];
const helloPromise = new Promise((resolve, reject) => {
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
- resolve(msg);
+ messages.push(msg);
+ if (msg.type === 'hello') resolve(msg);
} catch (err) {
reject(err);
}
@@ -55,10 +63,19 @@ test('server HTTP and WebSocket signaling endpoints', async (t) => {
});
const msg = await helloPromise;
- ws.close();
assert.strictEqual(msg.type, 'hello');
assert.strictEqual(msg.room, 'testroom');
assert.ok(msg.id, 'client id assigned');
+
+ // test /api/rooms while tester is connected
+ const resRoomsActive = await fetch(`http://127.0.0.1:${port}/api/rooms`);
+ assert.strictEqual(resRoomsActive.status, 200);
+ const roomsActive = await resRoomsActive.json();
+ assert.ok(roomsActive.testroom, 'testroom present in /api/rooms');
+ assert.strictEqual(roomsActive.testroom.length, 1);
+ assert.strictEqual(roomsActive.testroom[0].name, 'tester');
+
+ ws.close();
});
test('TURN credentials generation with TURN_SECRET', () => {