Files
gibbz/public/app.js
T
matsandClaude Sonnet 5 5bde1757d6
Build and Publish / Metadata (push) Successful in 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Require a nickname before joining and add a quick-start help modal
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>
2026-09-22 11:17:05 +02:00

1412 lines
49 KiB
JavaScript

import * as matrixcs from 'matrix-js-sdk';
import Konva from 'konva';
// ---------------------------------------------------------------------------
// 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'),
};
// ---------------------------------------------------------------------------
// 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();
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');
}
els.mic.disabled = !S.micStream;
S.muted = false;
updateMicButton();
$('#btn-share').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; }
els.mic.disabled = false;
// Keep btn-share enabled so clicking it 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 = '') {
if (els.status) {
// 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…';
}
els.status.textContent = shortText;
}
if (els.statusRow) els.statusRow.title = text;
if (els.statusDot) {
els.statusDot.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;
}
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,
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, restarting ICE', peer.id);
try { pc.restartIce(); } catch {}
}
};
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;
}
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);
peer.micStream = null;
peer.screenStream = null;
peer.els = {};
renderScreens();
}
// ---------------------------------------------------------------------------
// remote media: mic -> spatial chain, screen -> big video + direct audio
// ---------------------------------------------------------------------------
function onRemoteTrack(peer, ev) {
const stream = ev.streams[0];
if (!stream) return;
if (stream.getVideoTracks().length > 0) {
peer.screenStream = stream;
stream.getVideoTracks().forEach((t) => {
t.addEventListener('ended', () => {
if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); }
});
});
renderScreens();
console.log('remote screen share from', peer.name);
} else {
peer.micStream = stream;
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();
$('#btn-share').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();
$('#btn-share').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">&#127908;&#65039;&#8205;&#10060;</span>';
if (S.deafened) meBadges.innerHTML += '<span class="badge-deafen" title="Deafened">&#128066;&#10060;</span>';
if (S.sharing) meBadges.innerHTML += '<span class="badge-share" title="Sharing screen">&#128421;&#65039;</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">&#127908;&#65039;&#8205;&#10060;</span>';
if (peer.state.sharing) badges.innerHTML += '<span class="badge-share" title="Sharing screen">&#128421;&#65039;</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);
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">&#127908;&#65039;&#8205;&#10060;</span>';
if (user.state?.sharing) badges.innerHTML += '<span class="badge-share" title="Sharing screen">&#128421;&#65039;</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">&#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';
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 (Konva) — the "me" node is draggable and stays visually
// pinned to the stage center: every world-unit it moves, the layer is
// re-centered by the same amount in the opposite direction, so the world
// pans underneath it instead of the dot sliding away from center.
// ---------------------------------------------------------------------------
const pad = 30;
let sx = 1, sy = 1; // px per world unit, recomputed on resize
const spatialStage = new Konva.Stage({ container: 'spatial', width: 1, height: 1 });
const worldLayer = new Konva.Layer();
spatialStage.add(worldLayer);
// grid lives inside worldLayer so it pans with the camera along with everyone else
const gridGroup = new Konva.Group({ listening: false });
worldLayer.add(gridGroup);
const rangeCircle = new Konva.Circle({
radius: 0, stroke: 'rgba(88,101,242,.35)', strokeWidth: 1, dash: [5, 5], listening: false,
});
worldLayer.add(rangeCircle);
const avatarNodes = new Map(); // id -> {group, circle, initial, nameText}
function avatarNode(id, isMe) {
let node = avatarNodes.get(id);
if (node) return node;
const r = isMe ? 16 : 13;
const group = new Konva.Group({ draggable: isMe });
const circle = new Konva.Circle({ radius: r, fill: '#5865f2' });
const initial = new Konva.Text({
text: '', fontStyle: 'bold', fontSize: Math.round(r), fill: '#fff',
width: r * 2, height: r * 2, offsetX: r, offsetY: r, align: 'center', verticalAlign: 'middle',
});
const nameText = new Konva.Text({
text: '', fontSize: 11, fill: isMe ? '#b5bac1' : '#80848e', y: r + 4, align: 'center',
});
group.add(circle, initial, nameText);
worldLayer.add(group);
if (isMe) {
group.dragBoundFunc(function (pos) {
// pos is in worldLayer's parent (stage) coordinates; clamp the
// resulting world position to the room bounds, not the screen.
const worldX = Math.min(ROOM_UNITS, Math.max(0, (pos.x - worldLayer.x()) / sx));
const worldY = Math.min(ROOM_UNITS, Math.max(0, (pos.y - worldLayer.y()) / sy));
return { x: worldLayer.x() + worldX * sx, y: worldLayer.y() + worldY * sy };
});
group.on('dragmove', () => {
S.me.x = (group.x() - worldLayer.x()) / sx;
S.me.y = (group.y() - worldLayer.y()) / sy;
recenterCamera();
sendMyPos();
});
group.on('dragstart', () => els.spatial.classList.add('dragging'));
group.on('dragend', () => {
els.spatial.classList.remove('dragging');
sendMyPos(true);
});
}
node = { group, circle, initial, nameText };
avatarNodes.set(id, node);
return node;
}
function recenterCamera() {
const cx = spatialStage.width() / 2, cy = spatialStage.height() / 2;
worldLayer.position({ x: cx - S.me.x * sx, y: cy - S.me.y * sy });
}
function layoutSpatialGrid() {
gridGroup.destroyChildren();
for (let i = 0; i <= ROOM_UNITS; i += 10) {
gridGroup.add(new Konva.Line({
points: [i * sx, 0, i * sx, ROOM_UNITS * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1,
}));
gridGroup.add(new Konva.Line({
points: [0, i * sy, ROOM_UNITS * sx, i * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1,
}));
}
}
function resizeSpatial() {
const r = els.spatial.getBoundingClientRect();
spatialStage.width(r.width);
spatialStage.height(r.height);
sx = (r.width - 2 * pad) / ROOM_UNITS;
sy = (r.height - 2 * pad) / ROOM_UNITS;
layoutSpatialGrid();
recenterCamera();
}
new ResizeObserver(resizeSpatial).observe(els.spatial);
resizeSpatial();
function updateAvatarNode(node, id, name, muted, speaking, isMe, worldX, worldY) {
// Safe to set even for "me" while dragging: dragmove already derived
// S.me from the node's own current position, so this is a no-op then.
node.group.position({ x: worldX * sx, y: worldY * sy });
node.circle.stroke(muted ? '#f23f43' : speaking ? '#23a55a' : '');
node.circle.strokeWidth(3);
node.circle.fill(colorFor(id));
node.initial.text((name || '?').slice(0, 1).toUpperCase());
node.nameText.text(name + (isMe ? ' (you)' : ''));
node.nameText.offsetX(node.nameText.width() / 2);
}
function drawSpatial() {
// range circle stays centered on "me", which the camera keeps at center
rangeCircle.position({ x: S.me.x * sx, y: S.me.y * sy });
rangeCircle.radius(Math.max(0, Math.min(56 * sx, spatialStage.height() / 2 - pad)));
const seen = new Set(['me']);
const meId = S.myId || 'me';
const meNode = avatarNode('me', true);
updateAvatarNode(meNode, meId, S.name || 'me', S.muted, els.meAvatar.classList.contains('speaking'), true, S.me.x, S.me.y);
meNode.group.moveToTop();
for (const peer of S.peers.values()) {
seen.add(peer.id);
const node = avatarNode(peer.id, false);
updateAvatarNode(node, peer.id, peer.name, peer.state.muted, peer.speaking, false, peer.x, peer.y);
}
for (const [id, node] of avatarNodes) {
if (!seen.has(id)) { node.group.destroy(); avatarNodes.delete(id); }
}
worldLayer.batchDraw();
}
requestAnimationFrame(function loop() { drawSpatial(); requestAnimationFrame(loop); });
// ---------------------------------------------------------------------------
// 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,
});
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;
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);
}
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();
}
els.mic.onclick = toggleMic;
els.deafen.onclick = toggleDeafen;
$('#btn-share').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 = () => {
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');
});
};
});
// 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 somewhere spread out, not dead-center on top of others
(() => {
let h = 0;
for (const c of S.myId || S.name) h = (h * 33 + c.charCodeAt(0)) >>> 0;
const a = (h % 360) * (Math.PI / 180);
S.me.x = 50 + 20 * Math.cos(a);
S.me.y = 50 + 20 * 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') $('#btn-share').click();
});
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);