- Sidebar (channel list + nickname) becomes an off-canvas drawer below 860px, opened via a new fixed top bar (hamburger + live status) and closed by tapping the backdrop or picking a channel. - Mic/deafen/share get an always-visible bottom control bar so they're reachable mid-call without opening the drawer; updateMicButton/ updateDeafenButton and their click wiring now target both the sidebar and mirror buttons via shared NodeLists instead of single elements. - Per-user volume popup (previously hover-only) now also toggles on tap, and repositions below the row instead of to the side, which would overflow a narrow drawer. - Screen-share tiles stack full-width, touch targets are enlarged, and the spatial map panel shrinks to leave room for the stage. Also includes the working tree's prior swap of the spatial view from Konva to Cytoscape.js (drag, scroll-to-zoom, fit-to-view, distance edges) — not authored in this change, carried along since it was already uncommitted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1605 lines
53 KiB
JavaScript
1605 lines
53 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;
|
|
}
|
|
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);
|
|
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 isDraggingNode = false;
|
|
|
|
// 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));
|
|
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) => {
|
|
isDraggingNode = true;
|
|
els.spatial.classList.add('dragging');
|
|
});
|
|
|
|
cy.on('drag', 'node', (evt) => {
|
|
const node = evt.target;
|
|
const pos = cyToRoomPos(node.position());
|
|
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
|
|
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) => {
|
|
isDraggingNode = false;
|
|
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 (!isDraggingNode) {
|
|
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 (!isDraggingNode || cy.$(':grabbed').id() !== 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,
|
|
});
|
|
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 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') (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);
|