diff --git a/public/app.js b/public/app.js index 6e2d4fc..b4dfa8c 100644 --- a/public/app.js +++ b/public/app.js @@ -139,7 +139,10 @@ async function tuneAudioSender(sender) { if (!sender?.getParameters || !sender?.setParameters) return; const params = sender.getParameters(); if (!params.encodings?.length) params.encodings = [{}]; - params.encodings[0].maxBitrate = 64000; + // 128 kbps keeps Opus in its fullband LD/MD modes with headroom for FEC; + // 64 kbps audibly compresses louder/denser speech. DTX stays off so the + // channel doesn't pump in and out of low-bitrate comfort noise. + params.encodings[0].maxBitrate = 128000; params.encodings[0].dtx = false; params.encodings[0].priority = 'high'; await sender.setParameters(params); @@ -1073,17 +1076,19 @@ function stopScreenShare(stream, silent) { // --------------------------------------------------------------------------- // position sync // --------------------------------------------------------------------------- -// Fans new joiners out from directly in front of the room's center: the -// first joiner lands straight ahead, then each next one alternates sides, -// widening by 10deg per pair (0, +10, -10, +20, -20, ...) instead of the -// old distance-maximizing grid search, which tended to dump joiners in a -// far corner rather than somewhere natural to face the group. +// First joiner takes the room's middle anchor; later joiners fan out around +// them — the 2nd participant lands straight ahead (0deg), then each next one +// alternates sides, widening by 10deg per pair (0, +10, -10, +20, -20, ...). +// Chosen once per client at join and broadcast via the signaling 'pos' +// message, so every client converges on the same layout. function chooseOpenParticipantPosition() { const ANCHOR = { x: 50, y: 50 }; const RADIUS = 24; const n = S.peers.size; - const step = Math.ceil(n / 2) * 10; - const angleDeg = n === 0 ? 0 : (n % 2 === 1 ? step : -step); + if (n === 0) return { x: ANCHOR.x, y: ANCHOR.y }; // first joiner sits in the middle + const k = n - 1; // fan slot: 0deg, +10, -10, +20, -20... + const mag = Math.ceil(k / 2) * 10; + const angleDeg = k === 0 ? 0 : (k % 2 === 1 ? mag : -mag); const rad = (angleDeg * Math.PI) / 180; return { x: ANCHOR.x + RADIUS * Math.sin(rad), @@ -1123,18 +1128,24 @@ function updateMicSignal(level = S.audioManager.getLevel()) { if (clipping) clipping.classList.toggle('hidden', !level.clipping); } +let meRosterEl = null; // roster row for "me", kept so speaking highlights don't need a rebuild setInterval(() => { if (!S.audioCtx) return; const localLevel = S.audioManager.getLevel(); updateMicSignal(localLevel); const meSpeaking = S.micStream && !S.muted && localLevel.rms > 0.04; els.meAvatar.classList.toggle('speaking', !!meSpeaking); - let changed = false; + // Toggle speaking highlights in place. A full renderTiles() here used to + // wipe and rebuild the roster DOM on every start/stop of speech — which + // destroyed the volume popup and its range input mid-drag, making the + // per-user volume control feel broken exactly while a peer was talking. + meRosterEl?.classList.toggle('speaking', !!meSpeaking); for (const peer of S.peers.values()) { const speaking = !!(peer.analyser && !S.deafened && rms(peer.analyser) > 0.03); - if (speaking !== peer.speaking) { peer.speaking = speaking; changed = true; } + if (speaking === peer.speaking) continue; + peer.speaking = speaking; + peer.els.rosterItem?.classList.toggle('speaking', speaking); } - if (changed) renderTiles(); updateScreenTileIndicators(); }, 150); @@ -1170,8 +1181,13 @@ function renderChannelCounts() { } function renderTiles() { + // Remember an open volume popup so it survives this rebuild (peer join/leave + // or a mute/share badge change) without snapping closed under the cursor. + const openVolPeerId = document.querySelector('.channel-user-item.show-vol[data-peer-id]')?.dataset.peerId || null; + // Clear any existing user rosters in voice channels document.querySelectorAll('.channel-users').forEach((el) => { el.innerHTML = ''; }); + meRosterEl = null; // For each voice channel element in the sidebar document.querySelectorAll('.channel.voice').forEach((channelEl) => { @@ -1189,6 +1205,7 @@ function renderTiles() { // 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' : ''); + meRosterEl = meItem; const meAv = document.createElement('div'); meAv.className = 'avatar user-line-avatar'; setAvatar(meAv, S.name, S.myId || 'me'); @@ -1226,6 +1243,7 @@ function renderTiles() { for (const peer of S.peers.values()) { const item = document.createElement('div'); item.className = 'channel-user-item' + (peer.speaking ? ' speaking' : ''); + item.dataset.peerId = peer.id; const av = document.createElement('div'); av.className = 'avatar user-line-avatar'; @@ -1271,7 +1289,8 @@ function renderTiles() { 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; + // setTargetAtTime ramps smoothly instead of zipper-clicking on drag + if (peer.gain && S.audioCtx && !S.deafened) peer.gain.gain.setTargetAtTime(peer.vol, S.audioCtx.currentTime, 0.02); }; volPop.append(volLabel, volInput); @@ -1283,6 +1302,7 @@ function renderTiles() { item.append(av, name, badges, volPop); roster.appendChild(item); + peer.els.rosterItem = item; } } else { // Remote / unjoined channel: render users from server roster snapshot @@ -1324,6 +1344,14 @@ function renderTiles() { } }); + // Re-open the volume popup on the rebuilt row for the peer whose popup was + // open before this structural rebuild. + if (openVolPeerId) { + document + .querySelector(`.channel-user-item[data-peer-id="${CSS.escape(openVolPeerId)}"]`) + ?.classList.add('show-vol'); + } + renderStageEmpty(); }