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', () => {