From a2be7cf6a54c8ef8adcfe39c24ad106b472625c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 10:02:03 +0200 Subject: [PATCH] feat(ui): move users to channel list with hover volume popup, dedicate main stage to screenshares, add ws ping --- public/app.js | 118 +++++++++++++++++++++++++++++++++++----------- public/index.html | 9 ++-- public/style.css | 103 +++++++++++++++++++++++++++++++++------- server.js | 4 ++ 4 files changed, 186 insertions(+), 48 deletions(-) diff --git a/public/app.js b/public/app.js index 550dee2..5d52b6e 100644 --- a/public/app.js +++ b/public/app.js @@ -27,12 +27,13 @@ const S = { iceServers: null, // resolved on join sharing: false, connecting: false, + pingTimer: null, }; window.__dbg = S; // POC debug handle const $ = (sel) => document.querySelector(sel); const els = { - stage: $('#stage'), screens: $('#screens'), tiles: $('#tiles'), + stage: $('#stage'), screens: $('#screens'), spatial: $('#spatial'), status: $('#voice-status'), nick: $('#nick'), meAvatar: $('#me-avatar'), mic: $('#btn-mic'), deafen: $('#btn-deafen'), }; @@ -118,6 +119,7 @@ function leaveVoice(reason) { if (S.sharing) stopScreenShare(true); for (const peer of S.peers.values()) destroyPeer(peer); S.peers.clear(); + if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; } if (S.ws) { try { S.ws.close(); } catch {} S.ws = null; } if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; } els.mic.disabled = false; @@ -151,12 +153,19 @@ function openSignaling(room) { try { handleSignal(msg); } catch (err) { console.error('signal handler error', msg.type, err); } }; ws.onclose = () => { + if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; } if (S.ws === ws && S.room) { toast('Signaling connection lost', 'error'); leaveVoice('disconnected'); } }; - ws.onopen = () => resolve(); + ws.onopen = () => { + if (S.pingTimer) clearInterval(S.pingTimer); + S.pingTimer = setInterval(() => { + send({ type: 'ping' }); + }, 10000); + resolve(); + }; }); } @@ -524,12 +533,11 @@ function renderAll() { } function renderStageEmpty() { - const anyone = S.peers.size > 0 || S.sharing || S.room; - els.stage.classList.toggle('empty', !anyone); + const hasScreens = activeShares().length > 0; + els.stage.classList.toggle('empty', !hasScreens); } function renderChannelCounts() { - // POC: server does not expose cross-room rosters; count is local-room only const counts = {}; for (const p of S.peers.values()) counts[S.room] = (counts[S.room] || 0) + 1; document.querySelectorAll('[data-room-count]').forEach((el) => { @@ -539,35 +547,91 @@ function renderChannelCounts() { } function renderTiles() { - els.tiles.innerHTML = ''; + // Clear any existing user rosters in voice channels + document.querySelectorAll('.channel-users').forEach((el) => { el.innerHTML = ''; }); + + if (!S.room) { + renderStageEmpty(); + 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 tile = document.createElement('div'); - tile.className = 'tile' + (peer.speaking ? ' speaking' : ''); + const item = document.createElement('div'); + item.className = 'channel-user-item' + (peer.speaking ? ' speaking' : ''); + const av = document.createElement('div'); - av.className = 'avatar'; + av.className = 'avatar user-line-avatar'; setAvatar(av, peer.name, peer.id); - const name = document.createElement('div'); - name.className = 'name'; + + const name = document.createElement('span'); + name.className = 'user-line-name'; name.textContent = peer.name; - const sub = document.createElement('div'); - sub.className = 'sub'; - const bits = []; - if (!peer.pc) bits.push('signaling…'); - else if (peer.pc.connectionState !== 'connected') bits.push(`${peer.pc.connectionState}…`); - else if (!peer.micStream && !peer.state.muted) bits.push('no audio yet'); - if (peer.state.muted) bits.push('muted'); - if (peer.state.sharing) bits.push('sharing'); - sub.innerHTML = bits.join(' ') || 'connected'; - const vol = document.createElement('input'); - vol.type = 'range'; vol.min = 0; vol.max = 2; vol.step = 0.05; vol.value = peer.vol; - vol.title = 'Volume'; - vol.oninput = () => { - peer.vol = Number(vol.value); + + 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; }; - tile.append(av, name, sub, vol); - els.tiles.appendChild(tile); + volPop.append(volLabel, volInput); + + item.append(av, name, badges, volPop); + roster.appendChild(item); } + renderStageEmpty(); } diff --git a/public/index.html b/public/index.html index 2ff5fa2..622acdb 100644 --- a/public/index.html +++ b/public/index.html @@ -56,11 +56,10 @@
-
-
🔊
-

Pick a voice channel to join

-

Peer-to-peer WebRTC voice & screen sharing. Drag avatars on the spatial plane to position who you hear.

+
🖥️
+

No active screen shares

+

When someone shares their screen, it will appear here. Click the screen button below or press S to share.

@@ -97,6 +96,6 @@
- + diff --git a/public/style.css b/public/style.css index f591612..1f3ff94 100644 --- a/public/style.css +++ b/public/style.css @@ -40,6 +40,7 @@ body { /* sidebar */ #sidebar { width: 250px; background: var(--bg-1); display: flex; flex-direction: column; flex-shrink: 0; + position: relative; z-index: 10; } #server-name { padding: 14px 16px; font-weight: 700; font-size: 15px; @@ -105,8 +106,8 @@ body { #stage.empty #stage-empty { display: grid; } #stage:not(.empty) #stage-empty { display: none; } -#screens { display: flex; gap: 12px; flex-wrap: wrap; } -#screens.multi { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); align-content: start; } +#screens { display: flex; gap: 12px; flex-wrap: wrap; flex: 1; min-height: 0; align-items: stretch; } +#screens.multi { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); align-content: stretch; } #screens.multi .screen-card { flex: none; } .screen-card.popped { min-height: 120px; display: grid; place-items: center; @@ -122,27 +123,97 @@ body { position: relative; background: #000; border-radius: var(--radius); overflow: hidden; flex: 1 1 480px; max-width: 100%; box-shadow: 0 8px 24px rgba(0,0,0,.4); } -.screen-card video { width: 100%; display: block; max-height: 62vh; object-fit: contain; } +.screen-card video { width: 100%; height: 100%; display: block; max-height: 100%; object-fit: contain; } .screen-card .label { position: absolute; left: 8px; bottom: 8px; background: rgba(0,0,0,.65); padding: 3px 8px; border-radius: 5px; font-size: 12px; font-weight: 600; } .screen-card .label .live { color: var(--red); font-weight: 800; margin-right: 5px; } -#tiles { display: flex; gap: 10px; flex-wrap: wrap; } -.tile { - background: var(--bg-1); border-radius: var(--radius); padding: 12px; width: 150px; - display: flex; flex-direction: column; align-items: center; gap: 6px; position: relative; - border: 2px solid transparent; +/* channel user lines */ +.channel-users { + margin: 2px 8px 8px 16px; + display: flex; + flex-direction: column; + gap: 2px; +} +.channel-user-item { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: 4px; + color: var(--text-1); + font-size: 13px; + cursor: default; +} +.channel-user-item:hover { + background: var(--bg-3); + color: var(--text-0); +} +.channel-user-item.speaking .user-line-avatar { + box-shadow: 0 0 0 2px var(--green); +} +.user-line-avatar { + width: 22px; + height: 22px; + font-size: 11px; +} +.user-line-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.user-line-badges { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; +} +.badge-conn { + color: var(--yellow); + font-weight: bold; +} +.badge-mute { + font-size: 11px; +} +.badge-share { + font-size: 11px; +} + +/* hover volume popup */ +.volume-popup { + position: absolute; + left: calc(100% + 6px); + top: 50%; + transform: translateY(-50%); + background: var(--bg-0); + border: 1px solid var(--bg-4); + border-radius: var(--radius); + padding: 8px 12px; + width: 150px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); + display: none; + z-index: 100; + pointer-events: auto; +} +.channel-user-item:hover .volume-popup { + display: flex; + flex-direction: column; + gap: 6px; +} +.volume-popup .vol-title { + font-size: 11px; + font-weight: 600; + color: var(--text-1); + white-space: nowrap; +} +.volume-popup input[type=range] { + width: 100%; + accent-color: var(--brand); } -.tile.speaking { border-color: var(--green); } -.tile .avatar { width: 56px; height: 56px; font-size: 24px; } -.tile .name { font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } -.tile .sub { font-size: 11px; color: var(--text-2); display: flex; gap: 6px; align-items: center; } -.tile .mute-tag { color: var(--red); } -.tile .share-tag { color: var(--yellow); } -.tile input[type=range] { width: 100%; accent-color: var(--brand); } -.tile .connecting { font-size: 11px; color: var(--yellow); } .avatar { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; diff --git a/server.js b/server.js index aa37919..876b2df 100644 --- a/server.js +++ b/server.js @@ -156,6 +156,10 @@ wss.on('connection', (socket, req) => { if (!msg || typeof msg.type !== 'string') return; switch (msg.type) { + case 'ping': { + send({ type: 'pong' }); + break; + } case 'pos': { const x = Number(msg.x), y = Number(msg.y); if (Number.isFinite(x) && Number.isFinite(y)) {