import * as matrixcs from 'matrix-js-sdk'; // --------------------------------------------------------------------------- // 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'), }; // --------------------------------------------------------------------------- // 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; $('#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(); }, 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 += '🎤️‍❌'; if (S.deafened) meBadges.innerHTML += '👂❌'; if (S.sharing) meBadges.innerHTML += '🖥️'; 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 += '…'; } if (peer.state.muted) badges.innerHTML += '🎤️‍❌'; if (peer.state.sharing) badges.innerHTML += '🖥️'; // 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 += '🎤️‍❌'; if (user.state?.sharing) badges.innerHTML += '🖥️'; 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'; 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 = '●'; label.appendChild(document.createTextNode(share.name)); 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); } } 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(`${share.name} — screen share `); 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 // --------------------------------------------------------------------------- const ctx2d = els.spatial.getContext('2d'); let dragging = null; function spatialMetrics() { const r = els.spatial.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; if (els.spatial.width !== r.width * dpr) { els.spatial.width = r.width * dpr; els.spatial.height = r.height * dpr; } ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0); const pad = 30; return { w: r.width, h: r.height, pad, toPx: (x, y) => [pad + (x / ROOM_UNITS) * (r.width - 2 * pad), pad + (y / ROOM_UNITS) * (r.height - 2 * pad)], fromPx: (px, py) => [((px - pad) / (r.width - 2 * pad)) * ROOM_UNITS, ((py - pad) / (r.height - 2 * pad)) * ROOM_UNITS] }; } function drawSpatial() { const m = spatialMetrics(); ctx2d.clearRect(0, 0, m.w, m.h); // grid ctx2d.strokeStyle = 'rgba(255,255,255,.05)'; ctx2d.lineWidth = 1; for (let i = 0; i <= ROOM_UNITS; i += 10) { const [gx, gy] = m.toPx(i, i); ctx2d.beginPath(); ctx2d.moveTo(gx, m.toPx(0, 0)[1]); ctx2d.lineTo(gx, m.toPx(0, 100)[1]); ctx2d.stroke(); ctx2d.beginPath(); ctx2d.moveTo(m.toPx(0, 0)[0], gy); ctx2d.lineTo(m.toPx(100, 0)[0], gy); ctx2d.stroke(); } // audible range around me (~gain 0.05 at 56 units with ref=4, rolloff=1.4) const [mx, my] = m.toPx(S.me.x, S.me.y); const rangeR = (56 / ROOM_UNITS) * (m.w - 2 * m.pad); ctx2d.strokeStyle = 'rgba(88,101,242,.35)'; ctx2d.setLineDash([5, 5]); ctx2d.beginPath(); ctx2d.arc(mx, my, Math.max(0, Math.min(rangeR, m.h - 2 * m.pad)), 0, Math.PI * 2); ctx2d.stroke(); ctx2d.setLineDash([]); // peers for (const peer of S.peers.values()) drawAvatarDot(peer.name, peer.id, peer.x, peer.y, peer.speaking, peer.state.muted, false, m); // me (draggable listener) drawAvatarDot(S.name || 'me', S.myId || 'me', S.me.x, S.me.y, els.meAvatar.classList.contains('speaking'), S.muted, true, m); if (!dragging) requestAnimationFrame(drawSpatial); } requestAnimationFrame(drawSpatial); function drawAvatarDot(name, id, x, y, speaking, muted, isMe, m) { const [px, py] = m.toPx(x, y); const r = isMe ? 16 : 13; ctx2d.beginPath(); ctx2d.arc(px, py, r, 0, Math.PI * 2); ctx2d.fillStyle = colorFor(id); ctx2d.fill(); if (speaking) { ctx2d.strokeStyle = '#23a55a'; ctx2d.lineWidth = 3; ctx2d.stroke(); } if (muted) { ctx2d.strokeStyle = '#f23f43'; ctx2d.lineWidth = 3; ctx2d.beginPath(); ctx2d.moveTo(px - r, py + r); ctx2d.lineTo(px + r, py - r); ctx2d.stroke(); } ctx2d.fillStyle = '#fff'; ctx2d.font = `bold ${Math.round(r)}px system-ui`; ctx2d.textAlign = 'center'; ctx2d.textBaseline = 'middle'; ctx2d.fillText(name.slice(0, 1).toUpperCase(), px, py + 1); ctx2d.font = '11px system-ui'; ctx2d.fillStyle = isMe ? '#b5bac1' : '#80848e'; ctx2d.fillText(name + (isMe ? ' (you)' : ''), px, py + r + 12); } els.spatial.addEventListener('pointerdown', (ev) => { const m = spatialMetrics(); const rect = els.spatial.getBoundingClientRect(); const px = ev.clientX - rect.left, py = ev.clientY - rect.top; const [ux, uy] = m.fromPx(px, py); const d = Math.hypot(ux - S.me.x, uy - S.me.y); if (d < 10) { dragging = { dx: S.me.x - ux, dy: S.me.y - uy }; els.spatial.classList.add('dragging'); els.spatial.setPointerCapture(ev.pointerId); } }); els.spatial.addEventListener('pointermove', (ev) => { if (!dragging) return; const m = spatialMetrics(); const rect = els.spatial.getBoundingClientRect(); const [ux, uy] = m.fromPx(ev.clientX - rect.left, ev.clientY - rect.top); S.me.x = Math.min(ROOM_UNITS, Math.max(0, ux + dragging.dx)); S.me.y = Math.min(ROOM_UNITS, Math.max(0, uy + dragging.dy)); sendMyPos(); }); els.spatial.addEventListener('pointerup', () => { if (!dragging) return; dragging = null; els.spatial.classList.remove('dragging'); sendMyPos(true); requestAnimationFrame(drawSpatial); // rAF loop stopped while dragging }); // --------------------------------------------------------------------------- // 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() { els.mic.classList.toggle('off', S.muted || !S.micStream); } 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', () => { S.name = els.nick.value.trim() || `guest-${Math.random().toString(36).slice(2, 6)}`; els.nick.value = S.name; localStorage.setItem(LS_NAME, S.name); setAvatar(els.meAvatar, S.name, S.myId || 'me'); }); if (!S.name) { S.name = `guest-${Math.random().toString(36).slice(2, 6)}`; els.nick.value = S.name; localStorage.setItem(LS_NAME, S.name); } 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) 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'); };