feat(ui): move users to channel list with hover volume popup, dedicate main stage to screenshares, add ws ping
This commit is contained in:
1 parent
d3fd800ea7
commit
a2be7cf6a5
4 files changed
+186
-48
No files matched your search
+91
-27
@@ -27,12 +27,13 @@ const S = {
|
|||||||
iceServers: null, // resolved on join
|
iceServers: null, // resolved on join
|
||||||
sharing: false,
|
sharing: false,
|
||||||
connecting: false,
|
connecting: false,
|
||||||
|
pingTimer: null,
|
||||||
};
|
};
|
||||||
window.__dbg = S; // POC debug handle
|
window.__dbg = S; // POC debug handle
|
||||||
|
|
||||||
const $ = (sel) => document.querySelector(sel);
|
const $ = (sel) => document.querySelector(sel);
|
||||||
const els = {
|
const els = {
|
||||||
stage: $('#stage'), screens: $('#screens'), tiles: $('#tiles'),
|
stage: $('#stage'), screens: $('#screens'),
|
||||||
spatial: $('#spatial'), status: $('#voice-status'), nick: $('#nick'),
|
spatial: $('#spatial'), status: $('#voice-status'), nick: $('#nick'),
|
||||||
meAvatar: $('#me-avatar'), mic: $('#btn-mic'), deafen: $('#btn-deafen'),
|
meAvatar: $('#me-avatar'), mic: $('#btn-mic'), deafen: $('#btn-deafen'),
|
||||||
};
|
};
|
||||||
@@ -118,6 +119,7 @@ function leaveVoice(reason) {
|
|||||||
if (S.sharing) stopScreenShare(true);
|
if (S.sharing) stopScreenShare(true);
|
||||||
for (const peer of S.peers.values()) destroyPeer(peer);
|
for (const peer of S.peers.values()) destroyPeer(peer);
|
||||||
S.peers.clear();
|
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.ws) { try { S.ws.close(); } catch {} S.ws = null; }
|
||||||
if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; }
|
if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; }
|
||||||
els.mic.disabled = false;
|
els.mic.disabled = false;
|
||||||
@@ -151,12 +153,19 @@ function openSignaling(room) {
|
|||||||
try { handleSignal(msg); } catch (err) { console.error('signal handler error', msg.type, err); }
|
try { handleSignal(msg); } catch (err) { console.error('signal handler error', msg.type, err); }
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
|
||||||
if (S.ws === ws && S.room) {
|
if (S.ws === ws && S.room) {
|
||||||
toast('Signaling connection lost', 'error');
|
toast('Signaling connection lost', 'error');
|
||||||
leaveVoice('disconnected');
|
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() {
|
function renderStageEmpty() {
|
||||||
const anyone = S.peers.size > 0 || S.sharing || S.room;
|
const hasScreens = activeShares().length > 0;
|
||||||
els.stage.classList.toggle('empty', !anyone);
|
els.stage.classList.toggle('empty', !hasScreens);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChannelCounts() {
|
function renderChannelCounts() {
|
||||||
// POC: server does not expose cross-room rosters; count is local-room only
|
|
||||||
const counts = {};
|
const counts = {};
|
||||||
for (const p of S.peers.values()) counts[S.room] = (counts[S.room] || 0) + 1;
|
for (const p of S.peers.values()) counts[S.room] = (counts[S.room] || 0) + 1;
|
||||||
document.querySelectorAll('[data-room-count]').forEach((el) => {
|
document.querySelectorAll('[data-room-count]').forEach((el) => {
|
||||||
@@ -539,35 +547,91 @@ function renderChannelCounts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTiles() {
|
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 += '<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()) {
|
for (const peer of S.peers.values()) {
|
||||||
const tile = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
tile.className = 'tile' + (peer.speaking ? ' speaking' : '');
|
item.className = 'channel-user-item' + (peer.speaking ? ' speaking' : '');
|
||||||
|
|
||||||
const av = document.createElement('div');
|
const av = document.createElement('div');
|
||||||
av.className = 'avatar';
|
av.className = 'avatar user-line-avatar';
|
||||||
setAvatar(av, peer.name, peer.id);
|
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;
|
name.textContent = peer.name;
|
||||||
const sub = document.createElement('div');
|
|
||||||
sub.className = 'sub';
|
const badges = document.createElement('span');
|
||||||
const bits = [];
|
badges.className = 'user-line-badges';
|
||||||
if (!peer.pc) bits.push('<span class="connecting">signaling…</span>');
|
if (!peer.pc || peer.pc.connectionState !== 'connected') {
|
||||||
else if (peer.pc.connectionState !== 'connected') bits.push(`<span class="connecting">${peer.pc.connectionState}…</span>`);
|
badges.innerHTML += '<span class="badge-conn" title="Connecting...">…</span>';
|
||||||
else if (!peer.micStream && !peer.state.muted) bits.push('<span class="connecting">no audio yet</span>');
|
}
|
||||||
if (peer.state.muted) bits.push('<span class="mute-tag">muted</span>');
|
if (peer.state.muted) badges.innerHTML += '<span class="badge-mute" title="Muted">🎤️‍❌</span>';
|
||||||
if (peer.state.sharing) bits.push('<span class="share-tag">sharing</span>');
|
if (peer.state.sharing) badges.innerHTML += '<span class="badge-share" title="Sharing screen">🖥️</span>';
|
||||||
sub.innerHTML = bits.join(' ') || 'connected';
|
|
||||||
const vol = document.createElement('input');
|
// Volume hover popup
|
||||||
vol.type = 'range'; vol.min = 0; vol.max = 2; vol.step = 0.05; vol.value = peer.vol;
|
const volPop = document.createElement('div');
|
||||||
vol.title = 'Volume';
|
volPop.className = 'volume-popup';
|
||||||
vol.oninput = () => {
|
const volLabel = document.createElement('div');
|
||||||
peer.vol = Number(vol.value);
|
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;
|
if (peer.gain && !S.deafened) peer.gain.gain.value = peer.vol;
|
||||||
};
|
};
|
||||||
tile.append(av, name, sub, vol);
|
volPop.append(volLabel, volInput);
|
||||||
els.tiles.appendChild(tile);
|
|
||||||
|
item.append(av, name, badges, volPop);
|
||||||
|
roster.appendChild(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStageEmpty();
|
renderStageEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -56,11 +56,10 @@
|
|||||||
<main id="main">
|
<main id="main">
|
||||||
<div id="stage">
|
<div id="stage">
|
||||||
<div id="screens"></div>
|
<div id="screens"></div>
|
||||||
<div id="tiles"></div>
|
|
||||||
<div id="stage-empty">
|
<div id="stage-empty">
|
||||||
<div class="big-icon">🔊</div>
|
<div class="big-icon">🖥️</div>
|
||||||
<h2>Pick a voice channel to join</h2>
|
<h2>No active screen shares</h2>
|
||||||
<p>Peer-to-peer WebRTC voice & screen sharing. Drag avatars on the spatial plane to position who you hear.</p>
|
<p>When someone shares their screen, it will appear here. Click the screen button below or press <b>S</b> to share.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,6 +96,6 @@
|
|||||||
|
|
||||||
<div id="toasts"></div>
|
<div id="toasts"></div>
|
||||||
|
|
||||||
<script src="app.js?v=8"></script>
|
<script src="app.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+87
-16
@@ -40,6 +40,7 @@ body {
|
|||||||
/* sidebar */
|
/* sidebar */
|
||||||
#sidebar {
|
#sidebar {
|
||||||
width: 250px; background: var(--bg-1); display: flex; flex-direction: column; flex-shrink: 0;
|
width: 250px; background: var(--bg-1); display: flex; flex-direction: column; flex-shrink: 0;
|
||||||
|
position: relative; z-index: 10;
|
||||||
}
|
}
|
||||||
#server-name {
|
#server-name {
|
||||||
padding: 14px 16px; font-weight: 700; font-size: 15px;
|
padding: 14px 16px; font-weight: 700; font-size: 15px;
|
||||||
@@ -105,8 +106,8 @@ body {
|
|||||||
#stage.empty #stage-empty { display: grid; }
|
#stage.empty #stage-empty { display: grid; }
|
||||||
#stage:not(.empty) #stage-empty { display: none; }
|
#stage:not(.empty) #stage-empty { display: none; }
|
||||||
|
|
||||||
#screens { display: flex; gap: 12px; flex-wrap: wrap; }
|
#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(340px, 1fr)); align-content: start; }
|
#screens.multi { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); align-content: stretch; }
|
||||||
#screens.multi .screen-card { flex: none; }
|
#screens.multi .screen-card { flex: none; }
|
||||||
.screen-card.popped {
|
.screen-card.popped {
|
||||||
min-height: 120px; display: grid; place-items: center;
|
min-height: 120px; display: grid; place-items: center;
|
||||||
@@ -122,27 +123,97 @@ body {
|
|||||||
position: relative; background: #000; border-radius: var(--radius); overflow: hidden;
|
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);
|
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 {
|
.screen-card .label {
|
||||||
position: absolute; left: 8px; bottom: 8px; background: rgba(0,0,0,.65);
|
position: absolute; left: 8px; bottom: 8px; background: rgba(0,0,0,.65);
|
||||||
padding: 3px 8px; border-radius: 5px; font-size: 12px; font-weight: 600;
|
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; }
|
.screen-card .label .live { color: var(--red); font-weight: 800; margin-right: 5px; }
|
||||||
|
|
||||||
#tiles { display: flex; gap: 10px; flex-wrap: wrap; }
|
/* channel user lines */
|
||||||
.tile {
|
.channel-users {
|
||||||
background: var(--bg-1); border-radius: var(--radius); padding: 12px; width: 150px;
|
margin: 2px 8px 8px 16px;
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 6px; position: relative;
|
display: flex;
|
||||||
border: 2px solid transparent;
|
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 {
|
.avatar {
|
||||||
width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
|
width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ wss.on('connection', (socket, req) => {
|
|||||||
if (!msg || typeof msg.type !== 'string') return;
|
if (!msg || typeof msg.type !== 'string') return;
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
|
case 'ping': {
|
||||||
|
send({ type: 'pong' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'pos': {
|
case 'pos': {
|
||||||
const x = Number(msg.x), y = Number(msg.y);
|
const x = Number(msg.x), y = Number(msg.y);
|
||||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||||
|
|||||||
Reference in new issue
Block a user