Clarify mic mute state, center spatial map on you, show talking/muted on shares
Build and Publish / Metadata (push) Successful in 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 23s

- Mic button now swaps glyph (mic vs muted) and syncs immediately on
  join/leave/load instead of relying on a subtle CSS underline.
- Spatial minimap camera now follows the local user, matching how the
  audio panning is already centered on them, instead of plotting
  absolute room coordinates stretched across whatever width the
  screen happens to be.
- Screen-share tiles now show a speaking glow and muted badge, kept in
  sync on the existing audio-meter tick without tearing down <video>
  elements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5 committed 2026-09-22 11:07:34 +02:00
1 parent 45d2a4911f
commit 5ecf415f64
2 files changed
+60 -17

No files matched your search

+56 -17
View File
@@ -96,6 +96,7 @@ async function joinVoice(room) {
}
els.mic.disabled = !S.micStream;
S.muted = false;
updateMicButton();
$('#btn-share').disabled = false;
try {
@@ -640,6 +641,7 @@ setInterval(() => {
if (speaking !== peer.speaking) { peer.speaking = speaking; changed = true; }
}
if (changed) renderTiles();
updateScreenTileIndicators();
}, 150);
// ---------------------------------------------------------------------------
@@ -825,6 +827,7 @@ function renderScreens() {
}
const card = document.createElement('div');
card.className = 'screen-card';
card.dataset.shareKey = share.key;
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
@@ -835,6 +838,9 @@ function renderScreens() {
label.className = 'label';
label.innerHTML = '<span class="live">&#9679;</span>';
label.appendChild(document.createTextNode(share.name));
const micBadge = document.createElement('span');
micBadge.className = 'tile-mic-badge';
label.appendChild(micBadge);
const pop = document.createElement('button');
pop.className = 'pop-btn';
pop.title = 'Pop out in new window';
@@ -843,6 +849,26 @@ function renderScreens() {
card.append(video, label, pop);
els.screens.appendChild(card);
}
updateScreenTileIndicators();
}
// Cheap per-tick refresh of speaking/muted state on screen-share tiles —
// avoids renderScreens()'s full teardown, which would restart <video> playback.
function updateScreenTileIndicators() {
for (const card of els.screens.querySelectorAll('.screen-card[data-share-key]')) {
const key = card.dataset.shareKey;
let speaking = false, muted = false;
if (key === 'me') {
speaking = els.meAvatar.classList.contains('speaking');
muted = S.muted;
} else {
const peer = S.peers.get(key);
if (peer) { speaking = peer.speaking; muted = peer.state.muted; }
}
card.classList.toggle('speaking', speaking);
const badge = card.querySelector('.tile-mic-badge');
if (badge) badge.classList.toggle('muted', muted);
}
}
function popOutShare(share) {
@@ -883,7 +909,11 @@ function closePopout(key) {
const ctx2d = els.spatial.getContext('2d');
let dragging = null;
function spatialMetrics() {
// Camera is always centered on the listener (me), matching how the audio
// panning already works (peer positions are relative to S.me). While
// dragging, the camera freezes at its pre-drag position so the world
// doesn't slide under the pointer as S.me moves.
function spatialMetrics(camX = S.me.x, camY = S.me.y) {
const r = els.spatial.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
if (els.spatial.width !== r.width * dpr) {
@@ -892,37 +922,41 @@ function spatialMetrics() {
}
ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0);
const pad = 30;
return { w: r.width, h: r.height, pad,
toPx: (x, y) => [pad + (x / ROOM_UNITS) * (r.width - 2 * pad),
pad + (y / ROOM_UNITS) * (r.height - 2 * pad)],
fromPx: (px, py) => [((px - pad) / (r.width - 2 * pad)) * ROOM_UNITS,
((py - pad) / (r.height - 2 * pad)) * ROOM_UNITS] };
const sx = (r.width - 2 * pad) / ROOM_UNITS;
const sy = (r.height - 2 * pad) / ROOM_UNITS;
const cx = r.width / 2, cy = r.height / 2;
return { w: r.width, h: r.height, pad, sx, sy,
toPx: (x, y) => [cx + (x - camX) * sx, cy + (y - camY) * sy],
fromPx: (px, py) => [camX + (px - cx) / sx, camY + (py - cy) / sy] };
}
function drawSpatial() {
const m = spatialMetrics();
const camX = dragging ? dragging.camX : S.me.x;
const camY = dragging ? dragging.camY : S.me.y;
const m = spatialMetrics(camX, camY);
ctx2d.clearRect(0, 0, m.w, m.h);
// grid
// grid (world bounds only — clamped so it doesn't imply an infinite plane)
ctx2d.strokeStyle = 'rgba(255,255,255,.05)';
ctx2d.lineWidth = 1;
for (let i = 0; i <= ROOM_UNITS; i += 10) {
const [gx, gy] = m.toPx(i, i);
ctx2d.beginPath(); ctx2d.moveTo(gx, m.toPx(0, 0)[1]); ctx2d.lineTo(gx, m.toPx(0, 100)[1]); ctx2d.stroke();
ctx2d.beginPath(); ctx2d.moveTo(m.toPx(0, 0)[0], gy); ctx2d.lineTo(m.toPx(100, 0)[0], gy); ctx2d.stroke();
const [gx] = m.toPx(i, 0);
const [, gy] = m.toPx(0, i);
ctx2d.beginPath(); ctx2d.moveTo(gx, m.toPx(0, 0)[1]); ctx2d.lineTo(gx, m.toPx(0, ROOM_UNITS)[1]); ctx2d.stroke();
ctx2d.beginPath(); ctx2d.moveTo(m.toPx(0, 0)[0], gy); ctx2d.lineTo(m.toPx(ROOM_UNITS, 0)[0], gy); ctx2d.stroke();
}
// audible range around me (~gain 0.05 at 56 units with ref=4, rolloff=1.4)
const [mx, my] = m.toPx(S.me.x, S.me.y);
const rangeR = (56 / ROOM_UNITS) * (m.w - 2 * m.pad);
const rangeR = 56 * m.sx;
ctx2d.strokeStyle = 'rgba(88,101,242,.35)';
ctx2d.setLineDash([5, 5]);
ctx2d.beginPath(); ctx2d.arc(mx, my, Math.max(0, Math.min(rangeR, m.h - 2 * m.pad)), 0, Math.PI * 2); ctx2d.stroke();
ctx2d.beginPath(); ctx2d.arc(mx, my, Math.max(0, Math.min(rangeR, m.h / 2 - m.pad)), 0, Math.PI * 2); ctx2d.stroke();
ctx2d.setLineDash([]);
// peers
for (const peer of S.peers.values()) drawAvatarDot(peer.name, peer.id, peer.x, peer.y, peer.speaking, peer.state.muted, false, m);
// me (draggable listener)
// me (draggable listener, always drawn at the camera's center)
drawAvatarDot(S.name || 'me', S.myId || 'me', S.me.x, S.me.y, els.meAvatar.classList.contains('speaking'), S.muted, true, m);
if (!dragging) requestAnimationFrame(drawSpatial);
@@ -966,18 +1000,19 @@ els.spatial.addEventListener('pointerdown', (ev) => {
const [ux, uy] = m.fromPx(px, py);
const d = Math.hypot(ux - S.me.x, uy - S.me.y);
if (d < 10) {
dragging = { dx: S.me.x - ux, dy: S.me.y - uy };
dragging = { dx: S.me.x - ux, dy: S.me.y - uy, camX: S.me.x, camY: S.me.y };
els.spatial.classList.add('dragging');
els.spatial.setPointerCapture(ev.pointerId);
}
});
els.spatial.addEventListener('pointermove', (ev) => {
if (!dragging) return;
const m = spatialMetrics();
const m = spatialMetrics(dragging.camX, dragging.camY);
const rect = els.spatial.getBoundingClientRect();
const [ux, uy] = m.fromPx(ev.clientX - rect.left, ev.clientY - rect.top);
S.me.x = Math.min(ROOM_UNITS, Math.max(0, ux + dragging.dx));
S.me.y = Math.min(ROOM_UNITS, Math.max(0, uy + dragging.dy));
drawSpatial();
sendMyPos();
});
els.spatial.addEventListener('pointerup', () => {
@@ -1198,7 +1233,9 @@ els.mxInput?.addEventListener('keydown', (ev) => {
// controls & wiring
// ---------------------------------------------------------------------------
function updateMicButton() {
els.mic.classList.toggle('off', S.muted || !S.micStream);
const off = S.muted || !S.micStream;
els.mic.classList.toggle('off', off);
els.mic.textContent = off ? '\u{1F507}' : '\u{1F3A4}'; // 🔇 muted/no-mic vs 🎤 live
}
function updateDeafenButton() {
els.deafen.classList.toggle('off', S.deafened);
@@ -1261,6 +1298,8 @@ document.querySelectorAll('.channel.voice').forEach((el) => {
});
// Initial roster fetch and lightweight periodic sync (especially for unjoined clients)
updateMicButton();
updateDeafenButton();
fetchRooms();
setInterval(() => {
// If not connected to websocket, poll for changes across rooms
+4
View File
@@ -144,6 +144,10 @@ body {
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.speaking { box-shadow: 0 0 0 3px var(--green), 0 8px 24px rgba(0,0,0,.4); }
.tile-mic-badge { margin-left: 6px; }
.tile-mic-badge.muted::before { content: "\1F507"; } /* 🔇 */
.tile-mic-badge:not(.muted)::before { content: ""; }
/* channel user lines */
.channel-users {