Show users in voice channels before joining
This commit is contained in:
1 parent
a29bd58b0c
commit
dee8ef4f7c
3 files changed
+185
-82
No files matched your search
+139
-79
@@ -27,6 +27,7 @@ const S = {
|
|||||||
connecting: false,
|
connecting: false,
|
||||||
pingTimer: null,
|
pingTimer: null,
|
||||||
reconnect: { room: null, attempt: 0, timer: null, countdownTimer: null },
|
reconnect: { room: null, attempt: 0, timer: null, countdownTimer: null },
|
||||||
|
channelRosters: {}, // roomId -> [snapshot]
|
||||||
};
|
};
|
||||||
window.__dbg = S; // POC debug handle
|
window.__dbg = S; // POC debug handle
|
||||||
|
|
||||||
@@ -116,6 +117,20 @@ async function fetchIce() {
|
|||||||
return res.json();
|
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) {
|
function leaveVoice(reason) {
|
||||||
if (!S.room && !S.ws) return;
|
if (!S.room && !S.ws) return;
|
||||||
if (S.sharing) stopScreenShare(true);
|
if (S.sharing) stopScreenShare(true);
|
||||||
@@ -135,6 +150,7 @@ function leaveVoice(reason) {
|
|||||||
updateDeafenButton();
|
updateDeafenButton();
|
||||||
renderAll();
|
renderAll();
|
||||||
setStatus(reason || 'not connected');
|
setStatus(reason || 'not connected');
|
||||||
|
fetchRooms();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(text, state = '') {
|
function setStatus(text, state = '') {
|
||||||
@@ -264,6 +280,12 @@ function send(msg) {
|
|||||||
|
|
||||||
function handleSignal(msg) {
|
function handleSignal(msg) {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
|
case 'rosters': {
|
||||||
|
S.channelRosters = msg.rooms || {};
|
||||||
|
renderTiles();
|
||||||
|
renderChannelCounts();
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'hello': {
|
case 'hello': {
|
||||||
S.myId = msg.id;
|
S.myId = msg.id;
|
||||||
S.room = msg.room;
|
S.room = msg.room;
|
||||||
@@ -628,10 +650,17 @@ function renderStageEmpty() {
|
|||||||
|
|
||||||
function renderChannelCounts() {
|
function renderChannelCounts() {
|
||||||
const counts = {};
|
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) => {
|
document.querySelectorAll('[data-room-count]').forEach((el) => {
|
||||||
const n = counts[el.dataset.roomCount] || 0;
|
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
|
// Clear any existing user rosters in voice channels
|
||||||
document.querySelectorAll('.channel-users').forEach((el) => { el.innerHTML = ''; });
|
document.querySelectorAll('.channel-users').forEach((el) => { el.innerHTML = ''; });
|
||||||
|
|
||||||
if (!S.room) {
|
// For each voice channel element in the sidebar
|
||||||
renderStageEmpty();
|
document.querySelectorAll('.channel.voice').forEach((channelEl) => {
|
||||||
return;
|
const roomId = channelEl.dataset.room;
|
||||||
}
|
if (!roomId) return;
|
||||||
|
|
||||||
const activeChannelEl = document.querySelector(`.channel.voice[data-room="${S.room}"]`);
|
let roster = channelEl.nextElementSibling;
|
||||||
if (!activeChannelEl) {
|
if (!roster || !roster.classList.contains('channel-users')) {
|
||||||
renderStageEmpty();
|
roster = document.createElement('div');
|
||||||
return;
|
roster.className = 'channel-users';
|
||||||
}
|
channelEl.after(roster);
|
||||||
|
|
||||||
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 += '<span class="badge-mute" title="Muted">🎤️‍❌</span>';
|
|
||||||
if (S.deafened) meBadges.innerHTML += '<span class="badge-deafen" title="Deafened">👂❌</span>';
|
|
||||||
if (S.sharing) meBadges.innerHTML += '<span class="badge-share" title="Sharing screen">🖥️</span>';
|
|
||||||
|
|
||||||
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 += '<span class="badge-conn" title="Connecting...">…</span>';
|
|
||||||
}
|
}
|
||||||
if (peer.state.muted) badges.innerHTML += '<span class="badge-mute" title="Muted">🎤️‍❌</span>';
|
|
||||||
if (peer.state.sharing) badges.innerHTML += '<span class="badge-share" title="Sharing screen">🖥️</span>';
|
|
||||||
|
|
||||||
// Volume hover popup
|
if (S.room && S.room === roomId) {
|
||||||
const volPop = document.createElement('div');
|
// Current active room: render Me + live peers (with interactive volume sliders)
|
||||||
volPop.className = 'volume-popup';
|
const meItem = document.createElement('div');
|
||||||
const volLabel = document.createElement('div');
|
meItem.className = 'channel-user-item' + (els.meAvatar.classList.contains('speaking') ? ' speaking' : '');
|
||||||
volLabel.className = 'vol-title';
|
const meAv = document.createElement('div');
|
||||||
volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`;
|
meAv.className = 'avatar user-line-avatar';
|
||||||
const volInput = document.createElement('input');
|
setAvatar(meAv, S.name, S.myId || 'me');
|
||||||
volInput.type = 'range';
|
const meName = document.createElement('span');
|
||||||
volInput.min = 0;
|
meName.className = 'user-line-name';
|
||||||
volInput.max = 2;
|
meName.textContent = `${S.name} (you)`;
|
||||||
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);
|
const meBadges = document.createElement('span');
|
||||||
roster.appendChild(item);
|
meBadges.className = 'user-line-badges';
|
||||||
}
|
if (S.muted) meBadges.innerHTML += '<span class="badge-mute" title="Muted">🎤️‍❌</span>';
|
||||||
|
if (S.deafened) meBadges.innerHTML += '<span class="badge-deafen" title="Deafened">👂❌</span>';
|
||||||
|
if (S.sharing) meBadges.innerHTML += '<span class="badge-share" title="Sharing screen">🖥️</span>';
|
||||||
|
|
||||||
|
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 += '<span class="badge-conn" title="Connecting...">…</span>';
|
||||||
|
}
|
||||||
|
if (peer.state.muted) badges.innerHTML += '<span class="badge-mute" title="Muted">🎤️‍❌</span>';
|
||||||
|
if (peer.state.sharing) badges.innerHTML += '<span class="badge-share" title="Sharing screen">🖥️</span>';
|
||||||
|
|
||||||
|
// 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 += '<span class="badge-mute" title="Muted">🎤️‍❌</span>';
|
||||||
|
if (user.state?.sharing) badges.innerHTML += '<span class="badge-share" title="Sharing screen">🖥️</span>';
|
||||||
|
|
||||||
|
item.append(av, name, badges);
|
||||||
|
roster.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
renderStageEmpty();
|
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
|
// spawn me somewhere spread out, not dead-center on top of others
|
||||||
(() => {
|
(() => {
|
||||||
let h = 0;
|
let h = 0;
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ const server = http.createServer((req, res) => {
|
|||||||
res.end(JSON.stringify(iceConfig()));
|
res.end(JSON.stringify(iceConfig()));
|
||||||
return;
|
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);
|
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) => {
|
wss.on('connection', (socket, req) => {
|
||||||
const q = new URL(req.url, 'http://x').searchParams;
|
const q = new URL(req.url, 'http://x').searchParams;
|
||||||
const roomId = (q.get('room') || 'general').slice(0, 64);
|
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) });
|
send({ type: 'hello', id, room: roomId, peers: [...room.values()].filter((p) => p.id !== id).map(snapshot) });
|
||||||
broadcast(room, { type: 'joined', peer: snapshot(client) }, id);
|
broadcast(room, { type: 'joined', peer: snapshot(client) }, id);
|
||||||
|
broadcastRosters();
|
||||||
console.log(`[signaling] ${name} (${id}) joined #${roomId} — ${room.size} in room`);
|
console.log(`[signaling] ${name} (${id}) joined #${roomId} — ${room.size} in room`);
|
||||||
|
|
||||||
socket.on('message', (raw) => {
|
socket.on('message', (raw) => {
|
||||||
@@ -178,6 +202,7 @@ wss.on('connection', (socket, req) => {
|
|||||||
sharing: !!msg.state?.sharing,
|
sharing: !!msg.state?.sharing,
|
||||||
};
|
};
|
||||||
broadcast(room, { type: 'state', from: id, state: client.state }, id);
|
broadcast(room, { type: 'state', from: id, state: client.state }, id);
|
||||||
|
broadcastRosters();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'offer':
|
case 'offer':
|
||||||
@@ -200,6 +225,7 @@ wss.on('connection', (socket, req) => {
|
|||||||
room.delete(id);
|
room.delete(id);
|
||||||
if (room.size === 0) rooms.delete(roomId);
|
if (room.size === 0) rooms.delete(roomId);
|
||||||
else broadcast(room, { type: 'left', id });
|
else broadcast(room, { type: 'left', id });
|
||||||
|
broadcastRosters();
|
||||||
console.log(`[signaling] ${name} (${id}) left #${roomId} — ${room.size} in room`);
|
console.log(`[signaling] ${name} (${id}) left #${roomId} — ${room.size} in room`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -235,6 +261,6 @@ if (require.main === module) {
|
|||||||
startServer();
|
startServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { server, wss, iceConfig, interval, startServer };
|
module.exports = { server, wss, iceConfig, allRoomsSnapshot, interval, startServer };
|
||||||
|
|
||||||
|
|
||||||
@@ -38,15 +38,23 @@ test('server HTTP and WebSocket signaling endpoints', async (t) => {
|
|||||||
const healthText = await resHealth.text();
|
const healthText = await resHealth.text();
|
||||||
assert.strictEqual(healthText, 'ok');
|
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
|
// test WebSocket signaling /ws
|
||||||
const { WebSocket } = require('ws');
|
const { WebSocket } = require('ws');
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?room=testroom&name=tester`);
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?room=testroom&name=tester`);
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
const helloPromise = new Promise((resolve, reject) => {
|
const helloPromise = new Promise((resolve, reject) => {
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(raw.toString());
|
const msg = JSON.parse(raw.toString());
|
||||||
resolve(msg);
|
messages.push(msg);
|
||||||
|
if (msg.type === 'hello') resolve(msg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
@@ -55,10 +63,19 @@ test('server HTTP and WebSocket signaling endpoints', async (t) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const msg = await helloPromise;
|
const msg = await helloPromise;
|
||||||
ws.close();
|
|
||||||
assert.strictEqual(msg.type, 'hello');
|
assert.strictEqual(msg.type, 'hello');
|
||||||
assert.strictEqual(msg.room, 'testroom');
|
assert.strictEqual(msg.room, 'testroom');
|
||||||
assert.ok(msg.id, 'client id assigned');
|
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', () => {
|
test('TURN credentials generation with TURN_SECRET', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user