"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>
1681 lines
58 KiB
JavaScript
1681 lines
58 KiB
JavaScript
import * as matrixcs from 'matrix-js-sdk';
|
|
import cytoscape from 'cytoscape';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// state
|
|
// ---------------------------------------------------------------------------
|
|
const ROOM_UNITS = 100; // spatial plane is 0..100 x 0..100
|
|
const SPATIAL_SCALE = 0.12; // room units -> audio world units
|
|
const FALLBACK_ICE = { iceServers: [{ urls: ['stun:turn.k6n.net:3478', 'stun:stun.l.google.com:19302'] }] };
|
|
const LS_NAME = 'voicepoc.nick';
|
|
const LS_ICE = 'voicepoc.iceOverride';
|
|
|
|
const S = {
|
|
ws: null,
|
|
myId: null,
|
|
room: null,
|
|
name: localStorage.getItem(LS_NAME) || '',
|
|
peers: new Map(), // id -> peer record
|
|
micStream: null,
|
|
screenStream: null,
|
|
screenSenders: new Map(), // peerId -> [RTCRtpSender]
|
|
audioCtx: null,
|
|
me: { x: 50, y: 50 },
|
|
muted: false,
|
|
deafened: false,
|
|
iceServers: null, // resolved on join
|
|
sharing: false,
|
|
connecting: false,
|
|
pingTimer: null,
|
|
reconnect: { room: null, attempt: 0, timer: null, countdownTimer: null },
|
|
channelRosters: {}, // roomId -> [snapshot]
|
|
};
|
|
window.__dbg = S; // POC debug handle
|
|
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const els = {
|
|
stage: $('#stage'), screens: $('#screens'),
|
|
spatial: $('#spatial'), status: $('#voice-status'), statusDot: $('#status-dot'),
|
|
statusRow: $('#status-row'), nick: $('#nick'),
|
|
meAvatar: $('#me-avatar'), mic: $('#btn-mic'), deafen: $('#btn-deafen'),
|
|
reconnectBanner: $('#reconnect-banner'), reconnectText: $('#reconnect-text'),
|
|
mxPanel: $('#mx-panel'), mxMessages: $('#mx-messages'), mxInput: $('#mx-input'),
|
|
welcomeBackdrop: $('#welcome-backdrop'), welcomeNick: $('#welcome-nick'), welcomeNickError: $('#welcome-nick-error'),
|
|
sidebar: $('#sidebar'), sidebarBackdrop: $('#sidebar-backdrop'), btnMenu: $('#btn-menu'),
|
|
};
|
|
// buttons that appear twice (sidebar + mobile bottom bar) — keep both in sync
|
|
const micButtons = [...document.querySelectorAll('#btn-mic, .mirror-mic')];
|
|
const deafenButtons = [...document.querySelectorAll('#btn-deafen, .mirror-deafen')];
|
|
const shareButtons = [...document.querySelectorAll('#btn-share, .mirror-share')];
|
|
const statusTextEls = [...document.querySelectorAll('#voice-status, #mobile-status-text')];
|
|
const statusDotEls = [...document.querySelectorAll('#status-dot, #mobile-status-dot')];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------------
|
|
function toast(msg, kind = '') {
|
|
const t = document.createElement('div');
|
|
t.className = `toast ${kind}`;
|
|
t.textContent = msg;
|
|
$('#toasts').appendChild(t);
|
|
setTimeout(() => t.remove(), 5000);
|
|
}
|
|
|
|
const PEER_COLORS = ['#5865f2', '#23a55a', '#f0b232', '#eb459e', '#3ba55c', '#faa61a', '#e67e22', '#9b59b6'];
|
|
function colorFor(id) {
|
|
let h = 0;
|
|
for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
return PEER_COLORS[h % PEER_COLORS.length];
|
|
}
|
|
function setAvatar(el, name, id) {
|
|
el.style.background = colorFor(id);
|
|
el.textContent = (name || '?').slice(0, 1).toUpperCase();
|
|
}
|
|
|
|
function effectiveIce() {
|
|
const raw = localStorage.getItem(LS_ICE);
|
|
if (raw) {
|
|
try { return JSON.parse(raw); } catch { /* fall through */ }
|
|
}
|
|
return S.iceServers || FALLBACK_ICE;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// join / leave
|
|
// ---------------------------------------------------------------------------
|
|
async function joinVoice(room) {
|
|
if (S.room === room || S.connecting) return;
|
|
if (S.room) leaveVoice('switching channel');
|
|
S.connecting = true;
|
|
setStatus(`connecting to ${room}…`, 'connecting');
|
|
|
|
// Mic permission & AudioContext: request immediately on the user-click gesture
|
|
// so browsers don't block the microphone permission prompt or audio autoplay policy.
|
|
if (!S.audioCtx) S.audioCtx = new AudioContext();
|
|
await S.audioCtx.resume().catch(() => {});
|
|
orientListener();
|
|
|
|
// Attach a one-time gesture listener in case browser autoplay policy keeps AudioContext suspended
|
|
const resumeCtx = () => {
|
|
if (S.audioCtx && S.audioCtx.state === 'suspended') {
|
|
S.audioCtx.resume().catch(() => {});
|
|
}
|
|
};
|
|
window.addEventListener('click', resumeCtx, { passive: true });
|
|
window.addEventListener('keydown', resumeCtx, { passive: true });
|
|
|
|
try {
|
|
S.micStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
});
|
|
} catch {
|
|
S.micStream = null;
|
|
toast('No microphone available — joining in listen-only mode', 'warn');
|
|
}
|
|
S.muted = false;
|
|
updateMicButton();
|
|
for (const btn of shareButtons) btn.disabled = false;
|
|
|
|
try {
|
|
S.iceServers = await fetchIce();
|
|
} catch {
|
|
toast('Could not load ICE config from server — using fallback STUN', 'warn');
|
|
}
|
|
|
|
await openSignaling(room);
|
|
S.room = room;
|
|
S.connecting = false;
|
|
renderAll();
|
|
}
|
|
|
|
async function fetchIce() {
|
|
const headers = window.rum && typeof window.rum.traceHeaders === 'function' ? window.rum.traceHeaders() : {};
|
|
const res = await fetch('/api/ice', { headers });
|
|
if (!res.ok) throw new Error(`ice ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function fetchRooms() {
|
|
try {
|
|
const headers = window.rum && typeof window.rum.traceHeaders === 'function' ? window.rum.traceHeaders() : {};
|
|
const res = await fetch('/api/rooms', { headers });
|
|
if (res.ok) {
|
|
S.channelRosters = await res.json();
|
|
renderTiles();
|
|
renderChannelCounts();
|
|
}
|
|
} catch (err) {
|
|
console.warn('fetchRooms failed', err);
|
|
}
|
|
}
|
|
|
|
function leaveVoice(reason) {
|
|
if (!S.room && !S.ws) return;
|
|
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; }
|
|
// Keep share buttons enabled so clicking one prompts the user to join a channel
|
|
updateMicButton();
|
|
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
|
S.room = null;
|
|
S.myId = null;
|
|
S.muted = false;
|
|
S.deafened = false;
|
|
updateDeafenButton();
|
|
renderAll();
|
|
setStatus(reason || 'not connected');
|
|
fetchRooms();
|
|
}
|
|
|
|
function setStatus(text, state = '') {
|
|
// Concise display text
|
|
let shortText = text;
|
|
if (text.startsWith('voice connected')) {
|
|
shortText = S.room ? `#${S.room}` : 'Connected';
|
|
} else if (text.startsWith('connecting to')) {
|
|
shortText = 'Connecting…';
|
|
}
|
|
for (const el of statusTextEls) el.textContent = shortText;
|
|
if (els.statusRow) els.statusRow.title = text;
|
|
for (const el of statusDotEls) el.className = 'status-indicator' + (state ? ` ${state}` : '');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// reconnection (server restarts / redeploys, dropped signaling, etc.)
|
|
// ---------------------------------------------------------------------------
|
|
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000]; // caps at 15s
|
|
|
|
function showReconnectBanner(text) {
|
|
els.reconnectText.textContent = text;
|
|
els.reconnectBanner.classList.remove('hidden');
|
|
}
|
|
function hideReconnectBanner() {
|
|
els.reconnectBanner.classList.add('hidden');
|
|
}
|
|
|
|
function beginReconnect(room) {
|
|
cancelReconnect();
|
|
S.reconnect.room = room;
|
|
attemptReconnect();
|
|
}
|
|
|
|
function cancelReconnect() {
|
|
const r = S.reconnect;
|
|
clearTimeout(r.timer);
|
|
clearInterval(r.countdownTimer);
|
|
r.timer = r.countdownTimer = null;
|
|
r.room = null;
|
|
r.attempt = 0;
|
|
hideReconnectBanner();
|
|
}
|
|
|
|
function attemptReconnect() {
|
|
const r = S.reconnect;
|
|
if (!r.room) return;
|
|
const delay = RECONNECT_DELAYS[Math.min(r.attempt, RECONNECT_DELAYS.length - 1)];
|
|
r.attempt++;
|
|
let remaining = delay;
|
|
const label = () => `Connection lost — reconnecting in ${Math.ceil(remaining / 1000)}s (attempt ${r.attempt})`;
|
|
showReconnectBanner(label());
|
|
clearInterval(r.countdownTimer);
|
|
r.countdownTimer = setInterval(() => {
|
|
remaining = Math.max(0, remaining - 1000);
|
|
if (remaining > 0) showReconnectBanner(label());
|
|
}, 1000);
|
|
|
|
clearTimeout(r.timer);
|
|
r.timer = setTimeout(() => doReconnectAttempt(), delay);
|
|
}
|
|
|
|
async function doReconnectAttempt() {
|
|
const r = S.reconnect;
|
|
if (!r.room) return;
|
|
clearInterval(r.countdownTimer);
|
|
const room = r.room;
|
|
showReconnectBanner('Reconnecting…');
|
|
try {
|
|
await joinVoice(room);
|
|
r.room = null;
|
|
r.attempt = 0;
|
|
hideReconnectBanner();
|
|
toast('Reconnected', 'ok');
|
|
} catch {
|
|
attemptReconnect();
|
|
}
|
|
}
|
|
|
|
$('#reconnect-retry').onclick = () => {
|
|
clearTimeout(S.reconnect.timer);
|
|
clearInterval(S.reconnect.countdownTimer);
|
|
doReconnectAttempt();
|
|
};
|
|
$('#reconnect-dismiss').onclick = () => cancelReconnect();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// signaling
|
|
// ---------------------------------------------------------------------------
|
|
function openSignaling(room) {
|
|
return new Promise((resolve, reject) => {
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const url = `${proto}://${location.host}/ws?room=${encodeURIComponent(room)}&name=${encodeURIComponent(S.name)}`;
|
|
const ws = new WebSocket(url);
|
|
S.ws = ws;
|
|
ws.onerror = () => { S.connecting = false; setStatus('connection failed', 'error'); reject(new Error('websocket failed')); };
|
|
ws.onmessage = (ev) => {
|
|
let msg;
|
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
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) {
|
|
const room = S.room;
|
|
leaveVoice('disconnected');
|
|
beginReconnect(room);
|
|
}
|
|
};
|
|
ws.onopen = () => {
|
|
if (S.pingTimer) clearInterval(S.pingTimer);
|
|
S.pingTimer = setInterval(() => {
|
|
send({ type: 'ping' });
|
|
}, 10000);
|
|
resolve();
|
|
};
|
|
});
|
|
}
|
|
|
|
function send(msg) {
|
|
if (S.ws && S.ws.readyState === 1) S.ws.send(JSON.stringify(msg));
|
|
}
|
|
|
|
function handleSignal(msg) {
|
|
switch (msg.type) {
|
|
case 'rosters': {
|
|
S.channelRosters = msg.rooms || {};
|
|
renderTiles();
|
|
renderChannelCounts();
|
|
break;
|
|
}
|
|
case 'hello': {
|
|
S.myId = msg.id;
|
|
S.room = msg.room;
|
|
for (const p of msg.peers) {
|
|
S.peers.set(p.id, makePeerRecord(p));
|
|
// deterministic initiator: lower id offers (no glare storm on join)
|
|
ensurePC(S.peers.get(p.id), S.myId < p.id);
|
|
}
|
|
sendMyPos(true);
|
|
renderAll();
|
|
setStatus(`voice connected — ${msg.peers.length + 1} in ${msg.room}`, 'online');
|
|
toast(`Joined ${msg.room}`, 'ok');
|
|
break;
|
|
}
|
|
case 'joined':
|
|
S.peers.set(msg.peer.id, makePeerRecord(msg.peer));
|
|
renderAll();
|
|
toast(`${msg.peer.name} joined`, 'ok');
|
|
break;
|
|
case 'left': {
|
|
const peer = S.peers.get(msg.id);
|
|
if (peer) {
|
|
destroyPeer(peer);
|
|
S.peers.delete(msg.id);
|
|
renderAll();
|
|
toast(`${peer.name} left`);
|
|
}
|
|
break;
|
|
}
|
|
case 'pos': {
|
|
const peer = S.peers.get(msg.from);
|
|
if (peer) { peer.x = msg.x; peer.y = msg.y; updatePanner(peer); }
|
|
break;
|
|
}
|
|
case 'state': {
|
|
const peer = S.peers.get(msg.from);
|
|
if (peer) { peer.state = msg.state; renderTiles(); }
|
|
break;
|
|
}
|
|
case 'offer':
|
|
case 'answer': {
|
|
const peer = ensurePC(S.peers.get(msg.from) || createOrphanPeer(msg.from), false);
|
|
onDescription(peer, msg.sdp).catch((e) => console.error('description failed', e));
|
|
break;
|
|
}
|
|
case 'candidate': {
|
|
const peer = S.peers.get(msg.from);
|
|
if (!peer || !peer.pc) return; // candidates only after description path created pc
|
|
onCandidate(peer, msg.candidate).catch((e) => console.error('candidate failed', e));
|
|
break;
|
|
}
|
|
case 'reset': {
|
|
// The other side's connection to us failed and it's rebuilding from
|
|
// scratch — tear down our half so a fresh offer lands on a clean
|
|
// RTCPeerConnection instead of an already-established (and now
|
|
// mismatched) DTLS transport. See recreatePeerConnection().
|
|
const peer = S.peers.get(msg.from);
|
|
if (peer) recreatePeerConnection(peer, { announce: false });
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// peer records + WebRTC (perfect negotiation)
|
|
// ---------------------------------------------------------------------------
|
|
function makePeerRecord(p) {
|
|
return {
|
|
id: p.id, name: p.name, x: p.x, y: p.y,
|
|
state: p.state || { muted: false, deafened: false, sharing: false },
|
|
pc: null, polite: false, makingOffer: false, ignoreOffer: false,
|
|
pendingCandidates: [], micStream: null, screenStream: null,
|
|
gain: null, panner: null, analyser: null, vol: 1, speaking: false,
|
|
resetAttempts: 0,
|
|
els: {},
|
|
};
|
|
}
|
|
|
|
function createOrphanPeer(id) {
|
|
const p = makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50 });
|
|
S.peers.set(id, p);
|
|
return p;
|
|
}
|
|
|
|
function ensurePC(peer, initiator) {
|
|
if (peer.pc) return peer;
|
|
const pc = new RTCPeerConnection({ ...effectiveIce(), iceCandidatePoolSize: 4 });
|
|
peer.pc = pc;
|
|
peer.polite = S.myId > peer.id;
|
|
|
|
pc.onnegotiationneeded = async () => {
|
|
try {
|
|
peer.makingOffer = true;
|
|
await pc.setLocalDescription();
|
|
send({ type: 'offer', to: peer.id, sdp: pc.localDescription });
|
|
} catch (err) {
|
|
console.error('negotiation failed', err);
|
|
} finally {
|
|
peer.makingOffer = false;
|
|
}
|
|
};
|
|
pc.onicecandidate = ({ candidate }) => {
|
|
if (candidate) send({ type: 'candidate', to: peer.id, candidate });
|
|
};
|
|
pc.onconnectionstatechange = () => {
|
|
renderTiles();
|
|
if (pc.connectionState === 'failed') {
|
|
console.warn('peer connection failed, rebuilding it from scratch', peer.id);
|
|
recreatePeerConnection(peer, { announce: true });
|
|
} else if (pc.connectionState === 'connected') {
|
|
peer.resetAttempts = 0; // a successful connection clears the failure count
|
|
}
|
|
};
|
|
pc.ontrack = (ev) => onRemoteTrack(peer, ev);
|
|
|
|
// send my mic; if I have none, ask to receive theirs
|
|
if (S.micStream) {
|
|
for (const track of S.micStream.getAudioTracks()) pc.addTrack(track, S.micStream);
|
|
} else {
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
}
|
|
|
|
// send my screen share if I am currently sharing when this peer connects
|
|
if (S.sharing && S.screenStream) {
|
|
const senders = [];
|
|
for (const track of S.screenStream.getTracks()) {
|
|
senders.push(pc.addTrack(track, S.screenStream));
|
|
}
|
|
S.screenSenders.set(peer.id, senders);
|
|
}
|
|
|
|
if (initiator) pc.onnegotiationneeded();
|
|
return peer;
|
|
}
|
|
|
|
const RESET_MAX_ATTEMPTS = 3;
|
|
|
|
// Once a connection reaches 'failed', its DTLS transport is done — restartIce()
|
|
// only restarts ICE gathering and reuses the same transport/certificate, which
|
|
// throws "Failed to set SSL role for the transport" if you try to renegotiate
|
|
// on it. The only real fix is a brand-new RTCPeerConnection (fresh certificate,
|
|
// no established role to conflict with). Since only one side may notice the
|
|
// failure first, whichever side detects it also tells the other to rebuild via
|
|
// a 'reset' signal — otherwise the other side keeps its old, now-mismatched
|
|
// transport and the fresh offer we send will fail against it too.
|
|
function recreatePeerConnection(peer, { announce }) {
|
|
if (announce) {
|
|
peer.resetAttempts++;
|
|
if (peer.resetAttempts > RESET_MAX_ATTEMPTS) {
|
|
toast(`Connection to ${peer.name} keeps failing — try leaving and rejoining the channel`, 'error');
|
|
return;
|
|
}
|
|
send({ type: 'reset', to: peer.id });
|
|
}
|
|
if (peer.pc) { try { peer.pc.close(); } catch {} }
|
|
peer.pc = null;
|
|
peer.makingOffer = false;
|
|
peer.ignoreOffer = false;
|
|
peer.pendingCandidates = [];
|
|
detachSpatial(peer);
|
|
if (peer.audioEl) { try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {} peer.audioEl = null; }
|
|
peer.micStream = null;
|
|
peer.screenStream = null;
|
|
S.screenSenders.delete(peer.id);
|
|
renderScreens();
|
|
renderTiles();
|
|
ensurePC(peer, S.myId < peer.id); // same deterministic-initiator rule as the first connection
|
|
}
|
|
|
|
async function onDescription(peer, desc) {
|
|
const pc = peer.pc;
|
|
const collision = desc.type === 'offer' && (peer.makingOffer || pc.signalingState !== 'stable');
|
|
peer.ignoreOffer = !peer.polite && collision;
|
|
if (peer.ignoreOffer) return; // impolite side ignores the rival offer
|
|
|
|
await pc.setRemoteDescription(desc); // modern browsers roll back implicitly when polite
|
|
for (const c of peer.pendingCandidates.splice(0)) {
|
|
await pc.addIceCandidate(c).catch((e) => console.warn('buffered candidate failed', e));
|
|
}
|
|
if (desc.type === 'offer') {
|
|
await pc.setLocalDescription();
|
|
send({ type: 'answer', to: peer.id, sdp: pc.localDescription });
|
|
}
|
|
}
|
|
|
|
async function onCandidate(peer, candidate) {
|
|
if (peer.ignoreOffer) return;
|
|
if (!peer.pc.remoteDescription) {
|
|
peer.pendingCandidates.push(candidate);
|
|
return;
|
|
}
|
|
await peer.pc.addIceCandidate(candidate);
|
|
}
|
|
|
|
function destroyPeer(peer) {
|
|
if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; }
|
|
detachSpatial(peer);
|
|
if (peer.audioEl) {
|
|
try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {}
|
|
peer.audioEl = null;
|
|
}
|
|
peer.micStream = null;
|
|
peer.screenStream = null;
|
|
peer.els = {};
|
|
renderScreens();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// remote media: mic -> spatial chain, screen -> big video + direct audio
|
|
// ---------------------------------------------------------------------------
|
|
function onRemoteTrack(peer, ev) {
|
|
const track = ev.track;
|
|
const stream = (ev.streams && ev.streams[0]) ? ev.streams[0] : new MediaStream([track]);
|
|
|
|
if (track.kind === 'video') {
|
|
peer.screenStream = stream;
|
|
track.addEventListener('ended', () => {
|
|
if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); }
|
|
});
|
|
renderScreens();
|
|
console.log('remote screen share from', peer.name);
|
|
} else if (track.kind === 'audio') {
|
|
peer.micStream = stream;
|
|
|
|
// Ensure AudioContext is active (resumes on Firefox/Safari if suspended)
|
|
if (S.audioCtx && S.audioCtx.state === 'suspended') {
|
|
S.audioCtx.resume().catch(() => {});
|
|
}
|
|
|
|
// Chrome/Safari benefit from having an Audio element attached to pump the stream
|
|
if (!peer.audioEl) {
|
|
peer.audioEl = new Audio();
|
|
peer.audioEl.srcObject = stream;
|
|
peer.audioEl.muted = true;
|
|
peer.audioEl.play().catch((e) => console.warn('peer audioEl autoplay', e));
|
|
}
|
|
attachSpatial(peer, stream);
|
|
console.log('remote mic from', peer.name);
|
|
}
|
|
renderTiles();
|
|
}
|
|
|
|
function attachSpatial(peer, stream) {
|
|
const ctx = S.audioCtx;
|
|
if (!ctx || peer.panner) return;
|
|
const src = ctx.createMediaStreamSource(stream);
|
|
const panner = ctx.createPanner();
|
|
panner.panningModel = 'HRTF';
|
|
panner.distanceModel = 'inverse';
|
|
panner.refDistance = 4;
|
|
panner.rolloffFactor = 1.4;
|
|
panner.maxDistance = 200;
|
|
const gain = ctx.createGain();
|
|
gain.gain.value = S.deafened ? 0 : peer.vol;
|
|
const analyser = ctx.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
src.connect(panner).connect(gain).connect(analyser).connect(ctx.destination);
|
|
Object.assign(peer, { panner, gain, analyser });
|
|
updatePanner(peer);
|
|
}
|
|
|
|
function detachSpatial(peer) {
|
|
if (peer.gain) { try { peer.gain.disconnect(); } catch {} }
|
|
if (peer.panner) { try { peer.panner.disconnect(); } catch {} }
|
|
peer.panner = peer.gain = peer.analyser = null;
|
|
}
|
|
|
|
function updatePanner(peer) {
|
|
if (!peer.panner) return;
|
|
const x = (peer.x - S.me.x) * SPATIAL_SCALE;
|
|
const z = (peer.y - S.me.y) * SPATIAL_SCALE; // screen-down = behind listener
|
|
const p = peer.panner;
|
|
if (p.positionX) {
|
|
p.positionX.value = x; p.positionY.value = 0; p.positionZ.value = z;
|
|
} else { // old Safari
|
|
p.setPosition(x, 0, z);
|
|
}
|
|
}
|
|
|
|
function orientListener() {
|
|
const l = S.audioCtx && S.audioCtx.listener;
|
|
if (!l) return;
|
|
if (l.forwardX) {
|
|
l.forwardX.value = 0; l.forwardY.value = 0; l.forwardZ.value = -1;
|
|
l.upX.value = 0; l.upY.value = 1; l.upZ.value = 0;
|
|
} else {
|
|
l.setOrientation(0, 0, -1, 0, 1, 0);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// screen share (local)
|
|
// ---------------------------------------------------------------------------
|
|
async function startScreenShare() {
|
|
if (S.sharing) {
|
|
stopScreenShare();
|
|
return;
|
|
}
|
|
if (!S.room) {
|
|
toast('Join a voice channel first to share your screen', 'warn');
|
|
return;
|
|
}
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
|
toast('Screen sharing is not supported in this browser or context', 'error');
|
|
return;
|
|
}
|
|
let stream;
|
|
try {
|
|
// Try requesting display media with audio first
|
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { frameRate: 30 }, audio: true,
|
|
});
|
|
} catch (err) {
|
|
// If user cancelled / denied, don't toast as error unless it's not NotAllowedError/AbortError
|
|
if (err.name === 'NotAllowedError' || err.name === 'AbortError') {
|
|
return;
|
|
}
|
|
// Try fallback without audio constraint if audio failed
|
|
try {
|
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { frameRate: 30 },
|
|
});
|
|
} catch (fallbackErr) {
|
|
if (fallbackErr.name === 'NotAllowedError' || fallbackErr.name === 'AbortError') {
|
|
return;
|
|
}
|
|
console.error('Failed to get display media:', fallbackErr);
|
|
toast(`Could not share screen: ${fallbackErr.message || fallbackErr}`, 'error');
|
|
return;
|
|
}
|
|
}
|
|
S.screenStream = stream;
|
|
S.sharing = true;
|
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: true } });
|
|
|
|
for (const peer of S.peers.values()) addScreenToPeer(peer);
|
|
const videoTrack = stream.getVideoTracks()[0];
|
|
if (videoTrack) {
|
|
videoTrack.addEventListener('ended', () => stopScreenShare());
|
|
}
|
|
renderScreens();
|
|
renderTiles();
|
|
for (const btn of shareButtons) btn.classList.add('active');
|
|
toast('You are sharing your screen', 'ok');
|
|
}
|
|
|
|
function addScreenToPeer(peer) {
|
|
if (!peer.pc || !S.screenStream) return;
|
|
const senders = [];
|
|
for (const track of S.screenStream.getTracks()) {
|
|
senders.push(peer.pc.addTrack(track, S.screenStream));
|
|
}
|
|
S.screenSenders.set(peer.id, senders); // renegotiation fires automatically
|
|
}
|
|
|
|
function stopScreenShare(silent) {
|
|
if (!S.sharing) return;
|
|
S.sharing = false;
|
|
for (const [peerId, senders] of S.screenSenders) {
|
|
const peer = S.peers.get(peerId);
|
|
if (peer && peer.pc) for (const s of senders) { try { peer.pc.removeTrack(s); } catch {} }
|
|
}
|
|
S.screenSenders.clear();
|
|
if (S.screenStream) { S.screenStream.getTracks().forEach((t) => t.stop()); S.screenStream = null; }
|
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: false } });
|
|
renderScreens();
|
|
renderTiles();
|
|
for (const btn of shareButtons) btn.classList.remove('active');
|
|
if (!silent) toast('Screen share stopped');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// position sync
|
|
// ---------------------------------------------------------------------------
|
|
let lastPosSend = 0;
|
|
function sendMyPos(force) {
|
|
const now = performance.now();
|
|
if (!force && now - lastPosSend < 60) return;
|
|
lastPosSend = now;
|
|
send({ type: 'pos', x: S.me.x, y: S.me.y });
|
|
for (const peer of S.peers.values()) updatePanner(peer);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// speaking detection (local meter + per-peer analyser RMS)
|
|
// ---------------------------------------------------------------------------
|
|
const meterBuf = new Uint8Array(256);
|
|
function rms(analyser) {
|
|
analyser.getByteTimeDomainData(meterBuf);
|
|
let sum = 0;
|
|
for (let i = 0; i < meterBuf.length; i++) {
|
|
const v = (meterBuf[i] - 128) / 128;
|
|
sum += v * v;
|
|
}
|
|
return Math.sqrt(sum / meterBuf.length);
|
|
}
|
|
|
|
let localAnalyser = null;
|
|
setInterval(() => {
|
|
if (!S.audioCtx) return;
|
|
if (S.micStream && !localAnalyser) {
|
|
const src = S.audioCtx.createMediaStreamSource(S.micStream);
|
|
localAnalyser = S.audioCtx.createAnalyser();
|
|
localAnalyser.fftSize = 256;
|
|
src.connect(localAnalyser); // tap only — not connected to destination (no echo)
|
|
}
|
|
const meSpeaking = localAnalyser && !S.muted && rms(localAnalyser) > 0.04;
|
|
els.meAvatar.classList.toggle('speaking', !!meSpeaking);
|
|
let changed = false;
|
|
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 (changed) renderTiles();
|
|
updateScreenTileIndicators();
|
|
}, 150);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rendering
|
|
// ---------------------------------------------------------------------------
|
|
function renderAll() {
|
|
renderTiles();
|
|
renderScreens();
|
|
renderStageEmpty();
|
|
renderChannelCounts();
|
|
}
|
|
|
|
function renderStageEmpty() {
|
|
const hasScreens = activeShares().length > 0;
|
|
els.stage.classList.toggle('empty', !hasScreens);
|
|
}
|
|
|
|
function renderChannelCounts() {
|
|
const counts = {};
|
|
// Count users across all rooms known from snapshot
|
|
for (const [rId, users] of Object.entries(S.channelRosters || {})) {
|
|
counts[rId] = users ? users.length : 0;
|
|
}
|
|
// If active room has peers, ensure active count includes me + peers
|
|
if (S.room) {
|
|
counts[S.room] = S.peers.size + 1;
|
|
}
|
|
document.querySelectorAll('[data-room-count]').forEach((el) => {
|
|
const n = counts[el.dataset.roomCount] || 0;
|
|
el.textContent = n ? `${n}` : '';
|
|
});
|
|
}
|
|
|
|
function renderTiles() {
|
|
// Clear any existing user rosters in voice channels
|
|
document.querySelectorAll('.channel-users').forEach((el) => { el.innerHTML = ''; });
|
|
|
|
// For each voice channel element in the sidebar
|
|
document.querySelectorAll('.channel.voice').forEach((channelEl) => {
|
|
const roomId = channelEl.dataset.room;
|
|
if (!roomId) return;
|
|
|
|
let roster = channelEl.nextElementSibling;
|
|
if (!roster || !roster.classList.contains('channel-users')) {
|
|
roster = document.createElement('div');
|
|
roster.className = 'channel-users';
|
|
channelEl.after(roster);
|
|
}
|
|
|
|
if (S.room && S.room === roomId) {
|
|
// 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' : '');
|
|
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);
|
|
|
|
for (const peer of S.peers.values()) {
|
|
const item = document.createElement('div');
|
|
item.className = 'channel-user-item' + (peer.speaking ? ' speaking' : '');
|
|
|
|
const av = document.createElement('div');
|
|
av.className = 'avatar user-line-avatar';
|
|
setAvatar(av, peer.name, peer.id);
|
|
|
|
const name = document.createElement('span');
|
|
name.className = 'user-line-name';
|
|
name.textContent = peer.name;
|
|
|
|
const badges = document.createElement('span');
|
|
badges.className = 'user-line-badges';
|
|
if (!peer.pc || peer.pc.connectionState !== 'connected') {
|
|
badges.innerHTML += '<span class="badge-conn" title="Connecting...">…</span>';
|
|
}
|
|
if (peer.state.muted) badges.innerHTML += '<span class="badge-mute" title="Muted">🎤️‍❌</span>';
|
|
if (peer.state.sharing) badges.innerHTML += '<span class="badge-share" title="Sharing screen">🖥️</span>';
|
|
|
|
// 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;
|
|
};
|
|
volPop.append(volLabel, volInput);
|
|
|
|
// Touch has no :hover — tap the row to reveal the volume popup instead.
|
|
item.addEventListener('click', (e) => {
|
|
if (e.target === volInput) return;
|
|
item.classList.toggle('show-vol');
|
|
});
|
|
|
|
item.append(av, name, badges, volPop);
|
|
roster.appendChild(item);
|
|
}
|
|
} else {
|
|
// Remote / unjoined channel: render users from server roster snapshot
|
|
const occupants = S.channelRosters && S.channelRosters[roomId];
|
|
if (Array.isArray(occupants) && occupants.length > 0) {
|
|
for (const user of occupants) {
|
|
const item = document.createElement('div');
|
|
item.className = 'channel-user-item';
|
|
|
|
const av = document.createElement('div');
|
|
av.className = 'avatar user-line-avatar';
|
|
setAvatar(av, user.name, user.id);
|
|
|
|
const name = document.createElement('span');
|
|
name.className = 'user-line-name';
|
|
name.textContent = user.name;
|
|
|
|
const badges = document.createElement('span');
|
|
badges.className = 'user-line-badges';
|
|
if (user.state?.muted) badges.innerHTML += '<span class="badge-mute" title="Muted">🎤️‍❌</span>';
|
|
if (user.state?.sharing) badges.innerHTML += '<span class="badge-share" title="Sharing screen">🖥️</span>';
|
|
|
|
item.append(av, name, badges);
|
|
roster.appendChild(item);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
renderStageEmpty();
|
|
}
|
|
|
|
// share keys: 'me' or peer id; popped-out shares render in their own window
|
|
const poppedWindows = new Map(); // key -> {win, name}
|
|
|
|
function activeShares() {
|
|
const shares = [];
|
|
if (S.sharing && S.screenStream) shares.push({ key: 'me', name: `${S.name} (you)`, stream: S.screenStream });
|
|
for (const peer of S.peers.values()) {
|
|
if (peer.screenStream) shares.push({ key: peer.id, name: peer.name, stream: peer.screenStream });
|
|
}
|
|
return shares;
|
|
}
|
|
|
|
function renderScreens() {
|
|
// prune popouts whose share ended
|
|
const live = new Set(activeShares().map((s) => s.key));
|
|
for (const [key, p] of [...poppedWindows]) {
|
|
if (!live.has(key)) {
|
|
try { p.win.close(); } catch {}
|
|
poppedWindows.delete(key);
|
|
}
|
|
}
|
|
|
|
els.screens.innerHTML = '';
|
|
els.screens.classList.toggle('multi', activeShares().length > 1);
|
|
for (const share of activeShares()) {
|
|
if (poppedWindows.has(share.key)) {
|
|
// rendered in its own window — show a small placeholder card to re-embed
|
|
const ph = document.createElement('div');
|
|
ph.className = 'screen-card popped';
|
|
const btn = document.createElement('button');
|
|
btn.className = 'pop-btn';
|
|
btn.textContent = '⧉ re-embed';
|
|
btn.onclick = () => closePopout(share.key);
|
|
const label = document.createElement('div');
|
|
label.className = 'label';
|
|
label.textContent = `${share.name} — popped out`;
|
|
ph.append(btn, label);
|
|
els.screens.appendChild(ph);
|
|
continue;
|
|
}
|
|
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;
|
|
video.muted = share.key === 'me';
|
|
video.srcObject = share.stream;
|
|
video.play().catch(() => {});
|
|
const label = document.createElement('div');
|
|
label.className = 'label';
|
|
label.innerHTML = '<span class="live">●</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';
|
|
pop.textContent = '⧉';
|
|
pop.onclick = () => popOutShare(share);
|
|
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) {
|
|
if (poppedWindows.has(share.key)) return;
|
|
const win = window.open('', `share-${share.key}`, 'width=960,height=540');
|
|
if (!win) {
|
|
toast('Pop-out blocked by the browser — allow pop-ups for this page', 'error');
|
|
return;
|
|
}
|
|
win.document.write(`<!doctype html><title>${share.name} — screen share</title>
|
|
<style>html,body{margin:0;height:100%;background:#000;display:grid}video{width:100%;height:100%;object-fit:contain}</style>
|
|
<video autoplay playsinline></video>`);
|
|
win.document.close();
|
|
const v = win.document.querySelector('video');
|
|
v.muted = share.key === 'me';
|
|
v.srcObject = share.stream;
|
|
v.play().catch(() => {});
|
|
poppedWindows.set(share.key, { win, name: share.name });
|
|
win.addEventListener('beforeunload', () => {
|
|
poppedWindows.delete(share.key);
|
|
renderScreens();
|
|
});
|
|
renderScreens();
|
|
}
|
|
|
|
function closePopout(key) {
|
|
const p = poppedWindows.get(key);
|
|
if (p) {
|
|
poppedWindows.delete(key);
|
|
try { p.win.close(); } catch {}
|
|
renderScreens();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// spatial canvas (Cytoscape.js) — graph visualization with auto-zoom-to-bounds,
|
|
// smooth node dragging, distance indicators, and speaking/hover highlights.
|
|
// ---------------------------------------------------------------------------
|
|
const cy = cytoscape({
|
|
container: els.spatial,
|
|
boxSelectionEnabled: false,
|
|
autounselectify: false,
|
|
userZoomingEnabled: true,
|
|
userPanningEnabled: true,
|
|
minZoom: 0.2,
|
|
maxZoom: 3.5,
|
|
style: [
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'shape': 'ellipse',
|
|
'width': 34,
|
|
'height': 34,
|
|
'background-color': 'data(color)',
|
|
'label': 'data(label)',
|
|
'text-valign': 'bottom',
|
|
'text-margin-y': 6,
|
|
'font-size': '11px',
|
|
'font-family': 'system-ui, -apple-system, sans-serif',
|
|
'color': '#80848e',
|
|
'text-background-opacity': 0.7,
|
|
'text-background-color': '#1e1f22',
|
|
'text-background-padding': '2px',
|
|
'text-background-shape': 'roundrectangle',
|
|
'border-width': 2,
|
|
'border-color': 'rgba(255,255,255,0.15)',
|
|
'transition-property': 'border-color, border-width, width, height',
|
|
'transition-duration': '0.15s',
|
|
},
|
|
},
|
|
{
|
|
selector: 'node[?isMe]',
|
|
style: {
|
|
'width': 40,
|
|
'height': 40,
|
|
'color': '#b5bac1',
|
|
'font-weight': 'bold',
|
|
'border-width': 3,
|
|
'border-color': 'rgba(255,255,255,0.4)',
|
|
},
|
|
},
|
|
{
|
|
selector: 'node.speaking',
|
|
style: {
|
|
'border-color': '#23a55a',
|
|
'border-width': 4,
|
|
},
|
|
},
|
|
{
|
|
selector: 'node.muted',
|
|
style: {
|
|
'border-color': '#f23f43',
|
|
'border-width': 3,
|
|
},
|
|
},
|
|
{
|
|
selector: 'node:selected',
|
|
style: {
|
|
'border-color': '#5865f2',
|
|
'border-width': 4,
|
|
},
|
|
},
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'width': 1,
|
|
'line-color': 'rgba(88, 101, 242, 0.25)',
|
|
'line-style': 'dashed',
|
|
'curve-style': 'bezier',
|
|
'label': 'data(label)',
|
|
'font-size': '9px',
|
|
'color': '#80848e',
|
|
'text-background-opacity': 0.6,
|
|
'text-background-color': '#111214',
|
|
'text-background-padding': '2px',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
let draggedNodeId = null; // set explicitly in grab/free — not re-derived via ':grabbed' each tick
|
|
|
|
// Convert spatial room units (0..100) to Cytoscape viewport pixels (scaled around center)
|
|
const SPATIAL_PX_SCALE = 6;
|
|
function roomToCyPos(x, y) {
|
|
return { x: (x - 50) * SPATIAL_PX_SCALE, y: (y - 50) * SPATIAL_PX_SCALE };
|
|
}
|
|
function cyToRoomPos(pos) {
|
|
const x = Math.min(ROOM_UNITS, Math.max(0, pos.x / SPATIAL_PX_SCALE + 50));
|
|
const y = Math.min(ROOM_UNITS, Math.max(0, pos.y / SPATIAL_PX_SCALE + 50));
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return null; // never let NaN/Infinity leak into S.me/peer.x/y
|
|
return { x, y };
|
|
}
|
|
|
|
// Fit / zoom viewport to all active users with comfortable padding
|
|
function zoomToBounds(animate = true) {
|
|
const nodes = cy.nodes();
|
|
if (nodes.length === 0) return;
|
|
if (animate) {
|
|
cy.stop();
|
|
cy.animate({
|
|
fit: { eles: nodes, padding: 60 },
|
|
duration: 350,
|
|
easing: 'ease-out-cubic',
|
|
});
|
|
} else {
|
|
cy.fit(nodes, 60);
|
|
}
|
|
}
|
|
|
|
// Handle dragging nodes
|
|
cy.on('grab', 'node', (evt) => {
|
|
cy.stop(); // cancel any in-flight fit animation so it can't fight the drag's pointer->model mapping
|
|
draggedNodeId = evt.target.id();
|
|
if (!evt.target.data('isMe')) {
|
|
toast(`Repositioning ${evt.target.data('label')} — this only changes how loud/where YOU hear them, not their real position`, 'warn');
|
|
}
|
|
els.spatial.classList.add('dragging');
|
|
});
|
|
|
|
cy.on('drag', 'node', (evt) => {
|
|
const node = evt.target;
|
|
const pos = cyToRoomPos(node.position());
|
|
if (!pos) return; // guard: never propagate a bad (NaN/Infinity) position into shared state
|
|
// cyToRoomPos already clamped pos to the room — snap the node's actual on-screen
|
|
// position back to that clamp too. Without this, Cytoscape lets the dot follow the
|
|
// pointer arbitrarily far past the edge (nothing was clamping node.position() itself,
|
|
// only the derived room coordinate), so it visually flew off while the stored
|
|
// position silently stayed pinned at the wall — then snapped back the instant you
|
|
// let go. This keeps everyone within the room, which given refDistance=4 /
|
|
// rolloffFactor=1.4 (see updatePanner) always keeps audio gain above ~18%, i.e.
|
|
// nobody can drag themselves or a peer out of hearing range.
|
|
node.position(roomToCyPos(pos.x, pos.y));
|
|
if (node.data('isMe')) {
|
|
S.me.x = pos.x;
|
|
S.me.y = pos.y;
|
|
sendMyPos();
|
|
} else {
|
|
// Peer dragged: update local audio position relative to me — never sent to anyone else
|
|
const peer = S.peers.get(node.id());
|
|
if (peer) {
|
|
peer.x = pos.x;
|
|
peer.y = pos.y;
|
|
updatePanner(peer);
|
|
}
|
|
}
|
|
updateSpatialEdges();
|
|
});
|
|
|
|
cy.on('free', 'node', (evt) => {
|
|
draggedNodeId = null;
|
|
els.spatial.classList.remove('dragging');
|
|
const node = evt.target;
|
|
if (node.data('isMe')) {
|
|
sendMyPos(true);
|
|
}
|
|
});
|
|
|
|
// Update connecting audio distance edges between 'me' and peers
|
|
function updateSpatialEdges() {
|
|
const meNode = cy.getElementById('me');
|
|
if (!meNode || meNode.empty()) return;
|
|
const mePos = meNode.position();
|
|
|
|
for (const peer of S.peers.values()) {
|
|
const peerNode = cy.getElementById(peer.id);
|
|
if (!peerNode || peerNode.empty()) continue;
|
|
const edgeId = `edge-me-${peer.id}`;
|
|
let edge = cy.getElementById(edgeId);
|
|
|
|
// Calculate 2D distance
|
|
const dist = Math.hypot(peer.x - S.me.x, peer.y - S.me.y);
|
|
const distText = `${Math.round(dist)}m`;
|
|
|
|
if (edge.empty()) {
|
|
cy.add({
|
|
group: 'edges',
|
|
data: { id: edgeId, source: 'me', target: peer.id, label: distText },
|
|
});
|
|
} else {
|
|
edge.data('label', distText);
|
|
}
|
|
}
|
|
|
|
// Remove orphan edges
|
|
cy.edges().forEach((edge) => {
|
|
const peerId = edge.data('target');
|
|
if (!S.peers.has(peerId)) {
|
|
edge.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
let lastPeerCount = -1;
|
|
|
|
function syncSpatialGraph() {
|
|
const meId = S.myId || 'me';
|
|
const meSpeaking = els.meAvatar.classList.contains('speaking');
|
|
|
|
// Upsert 'me' node
|
|
let meNode = cy.getElementById('me');
|
|
if (meNode.empty()) {
|
|
meNode = cy.add({
|
|
group: 'nodes',
|
|
data: {
|
|
id: 'me',
|
|
label: `${S.name || 'me'} (you)`,
|
|
color: colorFor(meId),
|
|
isMe: true,
|
|
},
|
|
position: roomToCyPos(S.me.x, S.me.y),
|
|
});
|
|
} else {
|
|
meNode.data('label', `${S.name || 'me'} (you)`);
|
|
meNode.data('color', colorFor(meId));
|
|
if (draggedNodeId !== 'me') {
|
|
meNode.position(roomToCyPos(S.me.x, S.me.y));
|
|
}
|
|
}
|
|
meNode.toggleClass('speaking', meSpeaking);
|
|
meNode.toggleClass('muted', !!S.muted);
|
|
|
|
// Upsert peers
|
|
const currentPeerIds = new Set();
|
|
for (const peer of S.peers.values()) {
|
|
currentPeerIds.add(peer.id);
|
|
let peerNode = cy.getElementById(peer.id);
|
|
if (peerNode.empty()) {
|
|
peerNode = cy.add({
|
|
group: 'nodes',
|
|
data: {
|
|
id: peer.id,
|
|
label: peer.name,
|
|
color: colorFor(peer.id),
|
|
isMe: false,
|
|
},
|
|
position: roomToCyPos(peer.x, peer.y),
|
|
});
|
|
} else {
|
|
peerNode.data('label', peer.name);
|
|
peerNode.data('color', colorFor(peer.id));
|
|
if (draggedNodeId !== peer.id) {
|
|
peerNode.position(roomToCyPos(peer.x, peer.y));
|
|
}
|
|
}
|
|
peerNode.toggleClass('speaking', !!peer.speaking);
|
|
peerNode.toggleClass('muted', !!peer.state?.muted);
|
|
}
|
|
|
|
// Remove nodes that have left
|
|
cy.nodes().forEach((node) => {
|
|
const id = node.id();
|
|
if (id !== 'me' && !currentPeerIds.has(id)) {
|
|
node.remove();
|
|
}
|
|
});
|
|
|
|
updateSpatialEdges();
|
|
|
|
// Auto-fit / zoom to bounds whenever participants count changes
|
|
const totalCount = S.peers.size + 1;
|
|
if (totalCount !== lastPeerCount) {
|
|
lastPeerCount = totalCount;
|
|
setTimeout(() => zoomToBounds(true), 50);
|
|
}
|
|
}
|
|
|
|
// Auto resize on container size change
|
|
new ResizeObserver(() => {
|
|
cy.resize();
|
|
zoomToBounds(false);
|
|
}).observe(els.spatial);
|
|
|
|
$('#btn-spatial-fit')?.addEventListener('click', () => zoomToBounds(true));
|
|
|
|
setInterval(syncSpatialGraph, 100);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Matrix text chat (experimental) — native OAuth2/OIDC auth against the
|
|
// homeserver's own authorization server (MSC2965/MSC3861), so there's no
|
|
// separate Matrix password: login redirects to matrix.k6n.net itself.
|
|
// No E2E crypto — rooms are plaintext, matching this app's POC posture.
|
|
// ---------------------------------------------------------------------------
|
|
const MX_BASE_URL = 'https://matrix.k6n.net';
|
|
const MX_SERVER_NAME = 'matrix.k6n.net';
|
|
const LS_MX_CLIENT_ID = 'voicepoc.mxClientId';
|
|
const LS_MX_SESSION = 'voicepoc.mxSession';
|
|
const SS_MX_PENDING = 'voicepoc.mxOidcPending';
|
|
|
|
const Mx = { client: null, roomId: null };
|
|
|
|
function mxRedirectUri() {
|
|
return location.origin + location.pathname;
|
|
}
|
|
|
|
async function mxRegisterClientId(authMetadata) {
|
|
const cached = localStorage.getItem(LS_MX_CLIENT_ID);
|
|
if (cached) return cached;
|
|
const clientId = await matrixcs.OAuth2.registerClient(authMetadata, {
|
|
client_name: 'gibbz voice',
|
|
client_uri: mxRedirectUri(),
|
|
redirect_uris: [mxRedirectUri()],
|
|
application_type: 'web',
|
|
});
|
|
localStorage.setItem(LS_MX_CLIENT_ID, clientId);
|
|
return clientId;
|
|
}
|
|
|
|
async function mxStartLogin() {
|
|
try {
|
|
const bare = matrixcs.createClient({ baseUrl: MX_BASE_URL });
|
|
const authMetadata = await bare.getAuthMetadata();
|
|
const clientId = await mxRegisterClientId(authMetadata);
|
|
const oauth = new matrixcs.OAuth2(authMetadata, { clientId, redirectUri: mxRedirectUri() });
|
|
const state = crypto.randomUUID();
|
|
sessionStorage.setItem(SS_MX_PENDING, JSON.stringify({
|
|
state,
|
|
authMetadata,
|
|
clientId,
|
|
deviceId: oauth.context.deviceId,
|
|
codeVerifier: oauth.context.codeVerifier,
|
|
redirectUri: oauth.context.redirectUri,
|
|
}));
|
|
const url = await oauth.generateAuthorizationCodeGrantUrl(state, 'query');
|
|
window.location.href = url;
|
|
} catch (err) {
|
|
console.error('matrix login failed to start', err);
|
|
toast(`Matrix login failed: ${err.message || err}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function mxCompleteLogin(code, state) {
|
|
const raw = sessionStorage.getItem(SS_MX_PENDING);
|
|
sessionStorage.removeItem(SS_MX_PENDING);
|
|
if (!raw) { toast('Matrix login callback received but no pending login found', 'error'); return; }
|
|
const pending = JSON.parse(raw);
|
|
if (pending.state !== state) { toast('Matrix login state mismatch — try again', 'error'); return; }
|
|
try {
|
|
const oauth = new matrixcs.OAuth2(pending.authMetadata, {
|
|
clientId: pending.clientId,
|
|
deviceId: pending.deviceId,
|
|
codeVerifier: pending.codeVerifier,
|
|
redirectUri: pending.redirectUri,
|
|
});
|
|
const tokens = await oauth.completeAuthorizationCodeGrant(code);
|
|
await mxAdoptTokens(tokens);
|
|
toast('Matrix connected', 'ok');
|
|
mxShowPanel();
|
|
} catch (err) {
|
|
console.error('matrix login completion failed', err);
|
|
toast(`Matrix login failed: ${err.message || err}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function mxAdoptTokens(tokens) {
|
|
const probe = matrixcs.createClient({ baseUrl: MX_BASE_URL, accessToken: tokens.access_token });
|
|
const who = await probe.whoami();
|
|
const session = {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || null,
|
|
userId: who.user_id,
|
|
deviceId: who.device_id,
|
|
};
|
|
localStorage.setItem(LS_MX_SESSION, JSON.stringify(session));
|
|
await mxBoot(session);
|
|
}
|
|
|
|
async function mxBoot(session) {
|
|
Mx.client = matrixcs.createClient({
|
|
baseUrl: MX_BASE_URL,
|
|
accessToken: session.accessToken,
|
|
refreshToken: session.refreshToken || undefined,
|
|
userId: session.userId,
|
|
deviceId: session.deviceId,
|
|
fallbackICEServerAllowed: false,
|
|
});
|
|
Mx.client.on(matrixcs.ClientEvent.Sync, (state) => {
|
|
if (state === 'PREPARED') mxOnReady();
|
|
});
|
|
Mx.client.on(matrixcs.RoomEvent.Timeline, (event, room) => {
|
|
if (room && room.roomId === Mx.roomId && event.getType() === 'm.room.message') mxRenderTimeline();
|
|
});
|
|
await Mx.client.startClient({ initialSyncLimit: 30 });
|
|
}
|
|
|
|
async function mxOnReady() {
|
|
try {
|
|
Mx.roomId = await mxJoinOrCreateRoom('gibbz-general', 'gibbz general');
|
|
mxRenderTimeline();
|
|
} catch (err) {
|
|
console.error('matrix room join/create failed', err);
|
|
toast(`Could not open #general text channel: ${err.message || err}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function mxJoinOrCreateRoom(aliasLocalPart, name) {
|
|
const alias = `#${aliasLocalPart}:${MX_SERVER_NAME}`;
|
|
try {
|
|
const res = await Mx.client.joinRoom(alias);
|
|
return res.roomId;
|
|
} catch (err) {
|
|
if (err.errcode !== 'M_NOT_FOUND') throw err;
|
|
const created = await Mx.client.createRoom({
|
|
room_alias_name: aliasLocalPart,
|
|
name,
|
|
visibility: 'public',
|
|
preset: 'public_chat',
|
|
});
|
|
return created.room_id;
|
|
}
|
|
}
|
|
|
|
function mxRenderTimeline() {
|
|
if (!Mx.client || !Mx.roomId || !els.mxMessages) return;
|
|
const room = Mx.client.getRoom(Mx.roomId);
|
|
if (!room) return;
|
|
const events = room.getLiveTimeline().getEvents().filter((e) => e.getType() === 'm.room.message');
|
|
els.mxMessages.innerHTML = '';
|
|
for (const ev of events) {
|
|
const row = document.createElement('div');
|
|
row.className = 'mx-msg';
|
|
const sender = document.createElement('span');
|
|
sender.className = 'mx-sender';
|
|
sender.textContent = (ev.sender && ev.sender.name) || ev.getSender();
|
|
const body = document.createElement('span');
|
|
body.className = 'mx-body';
|
|
body.textContent = ev.getContent().body || '';
|
|
row.append(sender, body);
|
|
els.mxMessages.appendChild(row);
|
|
}
|
|
els.mxMessages.scrollTop = els.mxMessages.scrollHeight;
|
|
}
|
|
|
|
function mxShowPanel() {
|
|
if (els.mxPanel) els.mxPanel.classList.remove('hidden');
|
|
}
|
|
|
|
function mxSend(text) {
|
|
if (!Mx.client || !Mx.roomId || !text.trim()) return;
|
|
Mx.client.sendTextMessage(Mx.roomId, text.trim()).catch((err) => {
|
|
console.error('matrix send failed', err);
|
|
toast(`Message failed to send: ${err.message || err}`, 'error');
|
|
});
|
|
}
|
|
|
|
async function mxInit() {
|
|
const params = new URLSearchParams(location.search);
|
|
if (params.has('code') && params.has('state')) {
|
|
const code = params.get('code'), state = params.get('state');
|
|
history.replaceState(null, '', location.pathname);
|
|
await mxCompleteLogin(code, state);
|
|
return;
|
|
}
|
|
const raw = localStorage.getItem(LS_MX_SESSION);
|
|
if (raw) {
|
|
try {
|
|
await mxBoot(JSON.parse(raw));
|
|
} catch (err) {
|
|
console.warn('resuming matrix session failed', err);
|
|
localStorage.removeItem(LS_MX_SESSION);
|
|
}
|
|
}
|
|
}
|
|
mxInit();
|
|
|
|
document.querySelectorAll('.channel.text').forEach((el) => {
|
|
el.onclick = () => {
|
|
if (Mx.roomId) { mxShowPanel(); return; }
|
|
if (Mx.client) { toast('Connecting to Matrix…'); return; }
|
|
mxStartLogin();
|
|
};
|
|
});
|
|
$('#mx-close')?.addEventListener('click', () => els.mxPanel.classList.add('hidden'));
|
|
$('#mx-send')?.addEventListener('click', () => {
|
|
mxSend(els.mxInput.value);
|
|
els.mxInput.value = '';
|
|
});
|
|
els.mxInput?.addEventListener('keydown', (ev) => {
|
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
ev.preventDefault();
|
|
$('#mx-send').click();
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// controls & wiring
|
|
// ---------------------------------------------------------------------------
|
|
function updateMicButton() {
|
|
const off = S.muted || !S.micStream;
|
|
for (const btn of micButtons) {
|
|
btn.classList.toggle('off', off);
|
|
btn.textContent = off ? '\u{1F507}' : '\u{1F3A4}'; // 🔇 muted/no-mic vs 🎤 live
|
|
btn.disabled = !S.micStream;
|
|
}
|
|
}
|
|
function updateDeafenButton() {
|
|
for (const btn of deafenButtons) btn.classList.toggle('off', S.deafened);
|
|
}
|
|
function sendState() {
|
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: S.sharing } });
|
|
}
|
|
|
|
function toggleMic() {
|
|
if (!S.micStream) return;
|
|
S.muted = !S.muted;
|
|
S.micStream.getAudioTracks().forEach((t) => (t.enabled = !S.muted));
|
|
updateMicButton();
|
|
sendState();
|
|
renderTiles();
|
|
}
|
|
|
|
function toggleDeafen() {
|
|
if (!S.audioCtx) return;
|
|
S.deafened = !S.deafened;
|
|
for (const peer of S.peers.values()) {
|
|
if (peer.gain) peer.gain.gain.value = S.deafened ? 0 : peer.vol;
|
|
}
|
|
updateDeafenButton();
|
|
sendState();
|
|
renderTiles();
|
|
}
|
|
|
|
for (const btn of micButtons) btn.onclick = toggleMic;
|
|
for (const btn of deafenButtons) btn.onclick = toggleDeafen;
|
|
for (const btn of shareButtons) btn.onclick = () => (S.sharing ? stopScreenShare() : startScreenShare());
|
|
$('#btn-settings').onclick = openSettings;
|
|
|
|
els.nick.value = S.name;
|
|
els.nick.addEventListener('change', () => {
|
|
const trimmed = els.nick.value.trim();
|
|
if (!trimmed) { els.nick.value = S.name; return; } // nickname can't be blanked out
|
|
S.name = trimmed;
|
|
els.nick.value = S.name;
|
|
localStorage.setItem(LS_NAME, S.name);
|
|
setAvatar(els.meAvatar, S.name, S.myId || 'me');
|
|
});
|
|
setAvatar(els.meAvatar, S.name, 'me');
|
|
|
|
document.querySelectorAll('.channel.voice').forEach((el) => {
|
|
el.onclick = () => {
|
|
closeSidebar();
|
|
if (S.room === el.dataset.room) return;
|
|
cancelReconnect();
|
|
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
|
el.classList.add('active');
|
|
joinVoice(el.dataset.room).catch((e) => {
|
|
console.error(e);
|
|
toast(`Join failed: ${e.message}`, 'error');
|
|
leaveVoice('join failed');
|
|
});
|
|
};
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// mobile channel drawer — the sidebar becomes an off-canvas panel below the
|
|
// #mobile-topbar breakpoint (see style.css); on desktop these are no-ops
|
|
// since the sidebar is never given the "open" class there.
|
|
// ---------------------------------------------------------------------------
|
|
function openSidebar() {
|
|
els.sidebar.classList.add('open');
|
|
els.sidebarBackdrop.classList.add('show');
|
|
}
|
|
function closeSidebar() {
|
|
els.sidebar.classList.remove('open');
|
|
els.sidebarBackdrop.classList.remove('show');
|
|
}
|
|
els.btnMenu.onclick = () => els.sidebar.classList.contains('open') ? closeSidebar() : openSidebar();
|
|
els.sidebarBackdrop.onclick = closeSidebar;
|
|
document.querySelectorAll('.channel.text').forEach((el) => el.addEventListener('click', closeSidebar));
|
|
|
|
// 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
|
|
if (!S.ws || S.ws.readyState !== 1) {
|
|
fetchRooms();
|
|
}
|
|
}, 5000);
|
|
|
|
// Spawn me slightly off-center so people don't land on top of each other.
|
|
// A single hashed angle on a *fixed* radius ring (the previous approach)
|
|
// puts anyone whose name hashes to a nearby angle almost exactly on top of
|
|
// each other too — on a small ring, "nearby angle" is only a few pixels of
|
|
// arc, and with just 360 angle buckets that's common (especially with the
|
|
// same recurring guest-xxxx test names). Hashing the radius independently
|
|
// from the angle means an angle collision no longer implies a full overlap.
|
|
// Both stay small enough that at refDistance=4/rolloffFactor=1.4 (see
|
|
// updatePanner) nobody starts out attenuated — this is purely about the
|
|
// dots being visually distinguishable, not audio range.
|
|
(() => {
|
|
const key = S.myId || S.name;
|
|
let hAngle = 0, hRadius = 0;
|
|
for (const c of key) {
|
|
hAngle = (hAngle * 33 + c.charCodeAt(0)) >>> 0;
|
|
hRadius = (hRadius * 17 + c.charCodeAt(0) + 7) >>> 0;
|
|
}
|
|
const a = (hAngle % 360) * (Math.PI / 180);
|
|
const r = 8 + (hRadius % 12); // 8..19 room units
|
|
S.me.x = 50 + r * Math.cos(a);
|
|
S.me.y = 50 + r * Math.sin(a);
|
|
})();
|
|
|
|
window.addEventListener('keydown', (ev) => {
|
|
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return;
|
|
if (ev.key === 'm') toggleMic();
|
|
if (ev.key === 'd') toggleDeafen();
|
|
if (ev.key === 's') (S.sharing ? stopScreenShare() : startScreenShare());
|
|
});
|
|
window.addEventListener('beforeunload', () => { try { cancelReconnect(); leaveVoice(); } catch {} });
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// settings modal (custom STUN/TURN)
|
|
// ---------------------------------------------------------------------------
|
|
function openSettings() {
|
|
const override = localStorage.getItem(LS_ICE);
|
|
$('#override-enabled').checked = !!override;
|
|
const current = override ? JSON.parse(override) : effectiveIce();
|
|
$('#ice-json').value = JSON.stringify(current, null, 2);
|
|
fetchIce().then(
|
|
(server) => { $('#ice-server-default').textContent = JSON.stringify(server, null, 2); },
|
|
() => { $('#ice-server-default').textContent = '(unavailable)'; },
|
|
);
|
|
$('#modal-backdrop').classList.remove('hidden');
|
|
}
|
|
$('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden');
|
|
$('#modal-backdrop').onclick = (ev) => {
|
|
if (ev.target === $('#modal-backdrop')) $('#modal-backdrop').classList.add('hidden');
|
|
};
|
|
$('#ice-reset').onclick = () => {
|
|
localStorage.removeItem(LS_ICE);
|
|
$('#override-enabled').checked = false;
|
|
fetchIce().then((server) => { $('#ice-json').value = JSON.stringify(server, null, 2); });
|
|
};
|
|
$('#ice-save').onclick = () => {
|
|
const enabled = $('#override-enabled').checked;
|
|
let parsed = null;
|
|
if (enabled) {
|
|
try {
|
|
parsed = JSON.parse($('#ice-json').value);
|
|
if (!Array.isArray(parsed.iceServers)) throw new Error('missing "iceServers" array');
|
|
} catch (err) {
|
|
toast(`Invalid ICE JSON: ${err.message}`, 'error');
|
|
return;
|
|
}
|
|
localStorage.setItem(LS_ICE, JSON.stringify(parsed));
|
|
} else {
|
|
localStorage.removeItem(LS_ICE);
|
|
}
|
|
$('#modal-backdrop').classList.add('hidden');
|
|
toast(enabled ? 'Custom ICE saved — rejoin to apply' : 'Reverted to server ICE — rejoin to apply', 'ok');
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// welcome modal — required nickname + quick-start info
|
|
// ---------------------------------------------------------------------------
|
|
let welcomeRequired = false;
|
|
function openWelcome(required) {
|
|
welcomeRequired = required;
|
|
els.welcomeNick.value = S.name;
|
|
els.welcomeNick.classList.remove('invalid');
|
|
els.welcomeNickError.classList.add('hidden');
|
|
els.welcomeBackdrop.classList.remove('hidden');
|
|
els.welcomeNick.focus();
|
|
}
|
|
function closeWelcome() { els.welcomeBackdrop.classList.add('hidden'); }
|
|
function submitWelcome() {
|
|
const trimmed = els.welcomeNick.value.trim();
|
|
if (!trimmed) {
|
|
els.welcomeNick.classList.add('invalid');
|
|
els.welcomeNickError.classList.remove('hidden');
|
|
els.welcomeNick.focus();
|
|
return;
|
|
}
|
|
S.name = trimmed;
|
|
els.nick.value = S.name;
|
|
localStorage.setItem(LS_NAME, S.name);
|
|
setAvatar(els.meAvatar, S.name, S.myId || 'me');
|
|
closeWelcome();
|
|
}
|
|
$('#welcome-continue').onclick = submitWelcome;
|
|
els.welcomeNick.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') submitWelcome(); });
|
|
els.welcomeBackdrop.onclick = (ev) => {
|
|
if (ev.target === els.welcomeBackdrop && !welcomeRequired) closeWelcome();
|
|
};
|
|
$('#btn-help').onclick = () => openWelcome(false);
|
|
|
|
if (!S.name) openWelcome(true);
|