A connection failing specifically when both peers are on the same LAN
(as opposed to over the open internet) usually means direct/STUN
candidates are being silently dropped by the router — either AP/router
client isolation (common on home/guest Wi-Fi) or no NAT hairpin/
loopback support for two LAN devices reaching each other via their own
public IP.
On the first retry after a connection failure, set
iceTransportPolicy: 'relay' so the rebuilt RTCPeerConnection only offers
TURN-relayed candidates, bypassing the LAN path entirely. Confirmed the
coturn secret and pod are up in-cluster so relay auth will actually
succeed. Only one side needs to do this — since it only offers relay
candidates, whatever pairs with them is forced through the relay
regardless of what the other side additionally tried.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"restarting ICE" on a connection that has reached 'failed' only restarts
ICE gathering — it reuses the same DTLS transport and certificate. If
that transport is what's actually broken, the next renegotiation throws
"Failed to set SSL role for the transport" (DTLS role can't be flipped
on an established transport), which is exactly what showed up in the
console on a LAN test, ruling out NAT/TURN as the cause.
Fix: on 'failed', fully close and recreate the RTCPeerConnection (fresh
certificate, nothing to conflict with) via recreatePeerConnection().
Since ICE/DTLS failure isn't guaranteed to fire on both ends at once,
the side that notices sends a new 'reset' signal (relayed by the
server like offer/answer/candidate) telling the other side to tear
down and wait for a fresh offer too — otherwise its old, now-mismatched
transport would just fail the same way against our new one. Capped at
3 rebuild attempts per peer before giving up with a toast, reset on any
successful 'connected' state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous spawn fix (radius 20 -> 6) traded one problem for another:
hashing only the angle onto a small fixed-radius ring means two names
whose hashes land near the same angle bucket (only 360 of them) now
separate by just a few pixels of arc instead of dozens, since arc
length scales with radius. Confirmed against the actual guest names
from a live session (guest-pxj6/guest-rd7o): old algorithm placed them
~11 room units apart, easily close enough for their ~34px graph nodes
to visually merge into what looked like a single dot.
Now the radius is hashed independently from the angle (8-19 units), so
an angle collision no longer implies a full positional overlap. Same
test names now land ~27.6 units apart. Still well within the range
that keeps audio at full gain (refDistance=4).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dragging (yourself or a peer, for local-only listening position) never
clamped the node's actual on-screen position — only the *derived* room
coordinate got clamped to [0,100]. Cytoscape let the dot follow the
pointer arbitrarily far past the edge while the stored position stayed
pinned at the wall, so it visually flew off, then snapped back the
instant you released. Now every drag frame writes the clamped position
back onto the node itself, so it can't separate from its real, bounded
position. Given refDistance=4/rolloffFactor=1.4, staying inside the room
already keeps everyone's audio gain above ~18%, so this is the actual
"never drag out of hearing range" bound.
Also replaced the ':grabbed' re-query in the 100ms sync loop (which
decided whether to skip re-snapping the actively-dragged node) with an
explicitly tracked node id — strictly more correct regardless of
whether it contributed to the glitch above.
Dragging a peer is local-only (repositions how loud/where only you
hear them, never sent to anyone else) but had no feedback saying so;
added a one-time toast when you grab a peer's dot.
Also shrink the initial spawn-scatter radius from 20 to 6 room units —
at radius 20, two people could spawn diametrically opposite (40 units
apart), which was already down to ~7% audio gain before anyone said a
word.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Sidebar (channel list + nickname) becomes an off-canvas drawer below
860px, opened via a new fixed top bar (hamburger + live status) and
closed by tapping the backdrop or picking a channel.
- Mic/deafen/share get an always-visible bottom control bar so they're
reachable mid-call without opening the drawer; updateMicButton/
updateDeafenButton and their click wiring now target both the
sidebar and mirror buttons via shared NodeLists instead of single
elements.
- Per-user volume popup (previously hover-only) now also toggles on
tap, and repositions below the row instead of to the side, which
would overflow a narrow drawer.
- Screen-share tiles stack full-width, touch targets are enlarged,
and the spatial map panel shrinks to leave room for the stage.
Also includes the working tree's prior swap of the spatial view from
Konva to Cytoscape.js (drag, scroll-to-zoom, fit-to-view, distance
edges) — not authored in this change, carried along since it was
already uncommitted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New visitors (no nickname in localStorage) get a blocking welcome
modal instead of a silently-generated guest-N name, so everyone in a
room has a name they actually chose. It doubles as quick-start help
(join a channel, mic/deafen/share shortcuts, drag-to-reposition on the
spatial map) reachable any time via the new ? button. The sidebar nick
field no longer falls back to a random name when cleared — it now
just rejects the blank edit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The custom pointer-capture + frozen-camera drag math was fragile and
felt worse than before. Konva handles the drag gesture itself; the
"me" node's screen position stays pinned to the stage center via
dragBoundFunc + a worldLayer offset that recenters on every dragmove,
so the world pans under the avatar instead of the avatar drifting off
center. Grid, peers, and the audible-range circle all live in the same
world-space layer so they pan together.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 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>