2387 lines
83 KiB
JavaScript
2387 lines
83 KiB
JavaScript
import * as matrixcs from 'matrix-js-sdk';
|
|
import cytoscape from 'cytoscape';
|
|
import { AudioManager } from './audio-manager.js';
|
|
|
|
function captureRumIssue(message, severity = 'ERROR', extra = {}) {
|
|
const rum = globalThis.OtelRUM?.last;
|
|
if (!rum || typeof rum.captureMessage !== 'function') return;
|
|
try {
|
|
rum.captureMessage(message, severity, extra);
|
|
} catch (err) {
|
|
console.warn('RUM capture failed', err);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('error', (event) => {
|
|
captureRumIssue(event.message || 'Unhandled browser error', 'ERROR', {
|
|
source: event.filename,
|
|
line: event.lineno,
|
|
column: event.colno,
|
|
});
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
const reason = event.reason;
|
|
captureRumIssue(`Unhandled promise rejection: ${reason?.message || reason || 'unknown error'}`, 'ERROR', {
|
|
name: reason?.name,
|
|
stack: reason?.stack,
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 LS_AUDIO_INPUT = 'voicepoc.audioInput';
|
|
const LS_SCREEN_HINT = 'voicepoc.screenHint';
|
|
|
|
const S = {
|
|
ws: null,
|
|
myId: null,
|
|
room: null,
|
|
name: localStorage.getItem(LS_NAME) || '',
|
|
peers: new Map(), // id -> peer record
|
|
micStream: null,
|
|
screenStreams: new Map(), // streamId -> local MediaStream
|
|
screenSenders: new Map(), // peerId -> Map(streamId -> RTCRtpSender[])
|
|
audioCtx: null,
|
|
audioManager: new AudioManager(),
|
|
rawMicStream: 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 = {
|
|
voiceView: $('#voice-view'),
|
|
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')];
|
|
|
|
function mIcon(name, extraClass = '') {
|
|
const span = document.createElement('span');
|
|
span.className = `material-symbols-outlined${extraClass ? ` ${extraClass}` : ''}`;
|
|
span.textContent = name;
|
|
return span;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 ICE_NOTICE_INTERVAL = 15000;
|
|
function notifyIceIssue(peer, message, extra = {}) {
|
|
const now = Date.now();
|
|
const key = `${message}:${extra.url || ''}`;
|
|
if (!peer || peer.lastIceNotice?.key !== key || now - peer.lastIceNotice.at > ICE_NOTICE_INTERVAL) {
|
|
toast(`${peer?.name || 'Voice'}: ${message}`, 'warn');
|
|
if (peer) peer.lastIceNotice = { key, at: now };
|
|
}
|
|
captureRumIssue(message, 'WARNING', {
|
|
peerId: peer?.id,
|
|
peerName: peer?.name,
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
async function selectedIcePath(pc) {
|
|
if (!pc?.getStats) return null;
|
|
const stats = await pc.getStats();
|
|
let pair;
|
|
stats.forEach((report) => {
|
|
if (report.type === 'candidate-pair' && (report.selected || report.nominated) && report.state === 'succeeded') {
|
|
pair = report;
|
|
}
|
|
});
|
|
if (!pair) return null;
|
|
const local = stats.get(pair.localCandidateId);
|
|
const remote = stats.get(pair.remoteCandidateId);
|
|
return {
|
|
localType: local?.candidateType,
|
|
remoteType: remote?.candidateType,
|
|
protocol: pair.protocol,
|
|
path: local?.candidateType === 'relay' || remote?.candidateType === 'relay' ? 'TURN relay' : 'direct/STUN',
|
|
};
|
|
}
|
|
|
|
async function tuneAudioSender(sender) {
|
|
if (!sender?.getParameters || !sender?.setParameters) return;
|
|
const params = sender.getParameters();
|
|
if (!params.encodings?.length) params.encodings = [{}];
|
|
params.encodings[0].maxBitrate = 64000;
|
|
params.encodings[0].dtx = false;
|
|
params.encodings[0].priority = 'high';
|
|
await sender.setParameters(params);
|
|
}
|
|
|
|
function preferOpus(pc) {
|
|
const capabilities = globalThis.RTCRtpReceiver?.getCapabilities?.('audio');
|
|
const codecs = capabilities?.codecs || [];
|
|
const opus = codecs.filter((codec) => codec.mimeType?.toLowerCase() === 'audio/opus');
|
|
if (!opus.length) return;
|
|
const ordered = [...opus, ...codecs.filter((codec) => !opus.includes(codec))];
|
|
for (const transceiver of pc.getTransceivers()) {
|
|
if (transceiver.receiver?.track?.kind === 'audio' || transceiver.sender?.track?.kind === 'audio') {
|
|
try { transceiver.setCodecPreferences(ordered); } catch (err) {
|
|
captureRumIssue('Could not prefer Opus audio codec', 'WARNING', { error: err?.message || String(err) });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function monitorPeerQuality(peer) {
|
|
if (peer.qualityTimer) clearInterval(peer.qualityTimer);
|
|
peer.qualityTimer = setInterval(async () => {
|
|
const pc = peer.pc;
|
|
if (!pc || pc.connectionState !== 'connected' || !pc.getStats) return;
|
|
try {
|
|
const stats = await pc.getStats();
|
|
let inbound;
|
|
let outbound;
|
|
let remoteInbound;
|
|
stats.forEach((report) => {
|
|
if (report.kind !== 'audio' && report.mediaType !== 'audio') return;
|
|
if (report.type === 'inbound-rtp') inbound = report;
|
|
if (report.type === 'outbound-rtp') outbound = report;
|
|
if (report.type === 'remote-inbound-rtp') remoteInbound = report;
|
|
});
|
|
const packets = (inbound?.packetsReceived || 0) + (inbound?.packetsLost || 0);
|
|
const lossRatio = packets > 20 ? (inbound.packetsLost || 0) / packets : 0;
|
|
const jitterMs = (inbound?.jitter || 0) * 1000;
|
|
const rttMs = (remoteInbound?.roundTripTime || 0) * 1000;
|
|
const issue = lossRatio >= 0.05 || jitterMs >= 80 || rttMs >= 500;
|
|
if (issue && (!peer.lastQualityNotice || Date.now() - peer.lastQualityNotice > ICE_NOTICE_INTERVAL)) {
|
|
peer.lastQualityNotice = Date.now();
|
|
const details = `quality degraded (loss ${Math.round(lossRatio * 100)}%, jitter ${Math.round(jitterMs)}ms${rttMs ? `, RTT ${Math.round(rttMs)}ms` : ''})`;
|
|
toast(`${peer.name}: ${details}`, 'warn');
|
|
captureRumIssue('WebRTC audio quality degraded', 'WARNING', {
|
|
peerId: peer.id,
|
|
peerName: peer.name,
|
|
lossRatio,
|
|
jitterMs,
|
|
rttMs,
|
|
packetsReceived: inbound?.packetsReceived,
|
|
packetsLost: inbound?.packetsLost,
|
|
bytesReceived: inbound?.bytesReceived,
|
|
bytesSent: outbound?.bytesSent,
|
|
path: peer.icePath?.path,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
captureRumIssue('Could not inspect WebRTC audio quality', 'WARNING', {
|
|
peerId: peer.id,
|
|
error: err?.message || String(err),
|
|
});
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
function playRetroCue(name) {
|
|
const ctx = S.audioCtx;
|
|
if (!ctx) return;
|
|
ctx.resume().catch(() => {});
|
|
|
|
const cues = {
|
|
join: [
|
|
{ frequency: 659, offset: 0, duration: 0.09, type: 'square' },
|
|
{ frequency: 988, offset: 0.08, duration: 0.16, type: 'square' },
|
|
],
|
|
leave: [
|
|
{ frequency: 523, offset: 0, duration: 0.11, type: 'triangle' },
|
|
{ frequency: 392, offset: 0.09, duration: 0.18, type: 'triangle' },
|
|
],
|
|
share: [
|
|
{ frequency: 523, offset: 0, duration: 0.08, type: 'square' },
|
|
{ frequency: 659, offset: 0.07, duration: 0.08, type: 'square' },
|
|
{ frequency: 784, offset: 0.14, duration: 0.08, type: 'square' },
|
|
{ frequency: 1047, offset: 0.21, duration: 0.22, type: 'triangle' },
|
|
],
|
|
};
|
|
const cue = cues[name];
|
|
if (!cue) return;
|
|
|
|
const start = ctx.currentTime;
|
|
for (const note of cue) {
|
|
const oscillator = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
const noteStart = start + note.offset;
|
|
oscillator.type = note.type;
|
|
oscillator.frequency.setValueAtTime(note.frequency, noteStart);
|
|
gain.gain.setValueAtTime(0.0001, noteStart);
|
|
gain.gain.exponentialRampToValueAtTime(0.055, noteStart + 0.008);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, noteStart + note.duration);
|
|
oscillator.connect(gain).connect(ctx.destination);
|
|
oscillator.start(noteStart);
|
|
oscillator.stop(noteStart + note.duration + 0.02);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) {
|
|
const audio = {
|
|
echoCancellation: true,
|
|
noiseSuppression: false,
|
|
autoGainControl: false,
|
|
channelCount: 1,
|
|
sampleRate: 48000,
|
|
sampleSize: 16,
|
|
};
|
|
if (deviceId) audio.deviceId = { exact: deviceId };
|
|
return { audio };
|
|
}
|
|
|
|
async function populateAudioInputs() {
|
|
const select = $('#audio-input');
|
|
if (!select || !navigator.mediaDevices?.enumerateDevices) return;
|
|
try {
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
const inputs = devices.filter((device) => device.kind === 'audioinput');
|
|
const selected = localStorage.getItem(LS_AUDIO_INPUT) || '';
|
|
select.replaceChildren(new Option('System default', ''));
|
|
inputs.forEach((device, index) => {
|
|
select.add(new Option(device.label || `Microphone ${index + 1}`, device.deviceId));
|
|
});
|
|
select.value = inputs.some((device) => device.deviceId === selected) ? selected : '';
|
|
if (selected && !select.value) localStorage.removeItem(LS_AUDIO_INPUT);
|
|
} catch (err) {
|
|
console.warn('enumerating audio inputs failed', err);
|
|
captureRumIssue('Could not enumerate audio inputs', 'WARNING', { error: err?.message || String(err) });
|
|
}
|
|
}
|
|
|
|
async function switchAudioInput(deviceId) {
|
|
const previousDeviceId = localStorage.getItem(LS_AUDIO_INPUT) || '';
|
|
if (deviceId) localStorage.setItem(LS_AUDIO_INPUT, deviceId);
|
|
else localStorage.removeItem(LS_AUDIO_INPUT);
|
|
if (!S.room || !S.micStream) return;
|
|
|
|
let nextStream;
|
|
try {
|
|
nextStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints(deviceId));
|
|
} catch (err) {
|
|
if (previousDeviceId) localStorage.setItem(LS_AUDIO_INPUT, previousDeviceId);
|
|
else localStorage.removeItem(LS_AUDIO_INPUT);
|
|
$('#audio-input').value = previousDeviceId;
|
|
toast(`Could not use that microphone: ${err.message || err}`, 'error');
|
|
return;
|
|
}
|
|
|
|
const nextOutput = await S.audioManager.attachInput(nextStream);
|
|
const nextTrack = nextOutput.getAudioTracks()[0];
|
|
nextTrack.enabled = !S.muted;
|
|
await Promise.all([...S.peers.values()].map(async (peer) => {
|
|
if (!peer.pc) return;
|
|
const sender = peer.pc.getSenders().find((item) => item.track && item.track.kind === 'audio');
|
|
if (sender) await sender.replaceTrack(nextTrack);
|
|
}));
|
|
S.rawMicStream = nextStream;
|
|
S.micStream = nextOutput;
|
|
updateMicButton();
|
|
toast('Microphone changed', 'ok');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
await S.audioManager.ensureContext();
|
|
S.audioCtx = S.audioManager.context;
|
|
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.rawMicStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints());
|
|
S.micStream = await S.audioManager.attachInput(S.rawMicStream);
|
|
populateAudioInputs();
|
|
} 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;
|
|
playRetroCue('join');
|
|
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);
|
|
captureRumIssue('Could not refresh voice channel rosters', 'WARNING', { error: err?.message || String(err) });
|
|
}
|
|
}
|
|
|
|
function leaveVoice(reason) {
|
|
if (!S.room && !S.ws) return;
|
|
const wasConnected = Boolean(S.room);
|
|
if (wasConnected) playRetroCue('leave');
|
|
if (S.sharing) stopScreenShare(undefined, 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; }
|
|
S.audioManager.stop();
|
|
S.rawMicStream = null;
|
|
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');
|
|
captureRumIssue('Voice signaling connection failed', 'ERROR', { room });
|
|
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);
|
|
captureRumIssue('Voice signaling message handler failed', 'ERROR', { type: msg.type, error: err?.message || String(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);
|
|
}
|
|
S.me = chooseOpenParticipantPosition();
|
|
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));
|
|
playRetroCue('join');
|
|
renderAll();
|
|
toast(`${msg.peer.name} joined`, 'ok');
|
|
break;
|
|
case 'left': {
|
|
const peer = S.peers.get(msg.id);
|
|
if (peer) {
|
|
playRetroCue('leave');
|
|
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);
|
|
captureRumIssue('WebRTC session description failed', 'ERROR', { peerId: peer.id, type: msg.type, error: e?.message || String(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);
|
|
captureRumIssue('WebRTC ICE candidate failed', 'WARNING', { peerId: peer.id, error: e?.message || String(e) });
|
|
});
|
|
break;
|
|
}
|
|
case 'reset': {
|
|
// The other side's connection to us failed and it's rebuilding from
|
|
// scratch — tear down our half so a fresh offer lands on a clean
|
|
// RTCPeerConnection instead of an already-established (and now
|
|
// mismatched) DTLS transport. See recreatePeerConnection().
|
|
const peer = S.peers.get(msg.from);
|
|
if (peer) recreatePeerConnection(peer, { announce: false });
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// peer records + WebRTC (perfect negotiation)
|
|
// ---------------------------------------------------------------------------
|
|
function makePeerRecord(p) {
|
|
return {
|
|
id: p.id, name: p.name, x: p.x, y: p.y,
|
|
state: p.state || { muted: false, deafened: false, sharing: false },
|
|
pc: null, polite: false, makingOffer: false, ignoreOffer: false,
|
|
pendingCandidates: [], micStream: null, screenStreams: new Map(),
|
|
gain: null, panner: null, analyser: null, vol: 1, speaking: false,
|
|
resetAttempts: 0,
|
|
lastIceNotice: null,
|
|
icePath: null,
|
|
qualityTimer: null,
|
|
lastQualityNotice: 0,
|
|
els: {},
|
|
};
|
|
}
|
|
|
|
function createOrphanPeer(id) {
|
|
const p = makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50 });
|
|
S.peers.set(id, p);
|
|
return p;
|
|
}
|
|
|
|
function ensurePC(peer, initiator) {
|
|
if (peer.pc) return peer;
|
|
const config = { ...effectiveIce(), iceCandidatePoolSize: 4 };
|
|
// After a failed retry, force everything through TURN relay — sidesteps
|
|
// AP/router client isolation and NAT-hairpin issues that are common when
|
|
// both peers are on the same LAN (direct/STUN candidates get silently
|
|
// dropped by the router in that case, so give up trying them).
|
|
if (peer.resetAttempts > 0) config.iceTransportPolicy = 'relay';
|
|
const pc = new RTCPeerConnection(config);
|
|
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);
|
|
captureRumIssue('WebRTC negotiation failed', 'ERROR', { peerId: peer.id, error: err?.message || String(err) });
|
|
} finally {
|
|
peer.makingOffer = false;
|
|
}
|
|
};
|
|
pc.onicecandidate = ({ candidate }) => {
|
|
if (candidate) send({ type: 'candidate', to: peer.id, candidate });
|
|
};
|
|
pc.onicecandidateerror = (event) => {
|
|
notifyIceIssue(peer, `ICE server unavailable (${event.errorCode || 'unknown'})`, {
|
|
peerId: peer.id,
|
|
iceConnectionState: pc.iceConnectionState,
|
|
iceGatheringState: pc.iceGatheringState,
|
|
errorCode: event.errorCode,
|
|
errorText: event.errorText,
|
|
url: event.url,
|
|
});
|
|
};
|
|
pc.oniceconnectionstatechange = () => {
|
|
const state = pc.iceConnectionState;
|
|
if (state === 'disconnected') {
|
|
notifyIceIssue(peer, 'ICE disconnected; audio may stop', {
|
|
iceConnectionState: state,
|
|
iceGatheringState: pc.iceGatheringState,
|
|
});
|
|
} else if (state === 'failed') {
|
|
notifyIceIssue(peer, 'ICE failed; trying TURN relay', {
|
|
iceConnectionState: state,
|
|
iceGatheringState: pc.iceGatheringState,
|
|
});
|
|
}
|
|
};
|
|
pc.onconnectionstatechange = () => {
|
|
renderTiles();
|
|
if (pc.connectionState === 'failed') {
|
|
console.warn('peer connection failed, rebuilding it from scratch', peer.id);
|
|
notifyIceIssue(peer, 'connection failed; rebuilding voice path', {
|
|
connectionState: pc.connectionState,
|
|
iceConnectionState: pc.iceConnectionState,
|
|
iceGatheringState: pc.iceGatheringState,
|
|
signalingState: pc.signalingState,
|
|
resetAttempts: peer.resetAttempts,
|
|
});
|
|
captureRumIssue('WebRTC peer connection failed', 'ERROR', {
|
|
peerId: peer.id,
|
|
state: pc.connectionState,
|
|
resetAttempts: peer.resetAttempts,
|
|
});
|
|
recreatePeerConnection(peer, { announce: true });
|
|
} else if (pc.connectionState === 'connected') {
|
|
peer.resetAttempts = 0; // a successful connection clears the failure count
|
|
selectedIcePath(pc).then((path) => {
|
|
if (!path) return;
|
|
peer.icePath = path;
|
|
toast(`${peer.name}: ${path.path} connection`, 'ok');
|
|
captureRumIssue('WebRTC connection established', 'INFO', {
|
|
peerId: peer.id,
|
|
peerName: peer.name,
|
|
connectionState: pc.connectionState,
|
|
iceConnectionState: pc.iceConnectionState,
|
|
...path,
|
|
});
|
|
}).catch((err) => {
|
|
captureRumIssue('Could not inspect selected ICE path', 'WARNING', {
|
|
peerId: peer.id,
|
|
error: err?.message || String(err),
|
|
});
|
|
});
|
|
monitorPeerQuality(peer);
|
|
}
|
|
};
|
|
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()) {
|
|
const sender = pc.addTrack(track, S.micStream);
|
|
tuneAudioSender(sender).catch((err) => captureRumIssue('Could not tune Opus audio sender', 'WARNING', {
|
|
peerId: peer.id,
|
|
error: err?.message || String(err),
|
|
}));
|
|
}
|
|
} else {
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
}
|
|
preferOpus(pc);
|
|
|
|
// send my screen share if I am currently sharing when this peer connects
|
|
if (S.sharing) {
|
|
const peerSenders = new Map();
|
|
for (const [streamId, stream] of S.screenStreams) {
|
|
const senders = [];
|
|
for (const track of stream.getTracks()) {
|
|
senders.push(pc.addTrack(track, stream));
|
|
}
|
|
peerSenders.set(streamId, senders);
|
|
}
|
|
S.screenSenders.set(peer.id, peerSenders);
|
|
}
|
|
|
|
if (initiator) pc.onnegotiationneeded();
|
|
return peer;
|
|
}
|
|
|
|
const RESET_MAX_ATTEMPTS = 3;
|
|
|
|
// Once a connection reaches 'failed', its DTLS transport is done — restartIce()
|
|
// only restarts ICE gathering and reuses the same transport/certificate, which
|
|
// throws "Failed to set SSL role for the transport" if you try to renegotiate
|
|
// on it. The only real fix is a brand-new RTCPeerConnection (fresh certificate,
|
|
// no established role to conflict with). Since only one side may notice the
|
|
// failure first, whichever side detects it also tells the other to rebuild via
|
|
// a 'reset' signal — otherwise the other side keeps its old, now-mismatched
|
|
// transport and the fresh offer we send will fail against it too.
|
|
function recreatePeerConnection(peer, { announce }) {
|
|
if (announce) {
|
|
peer.resetAttempts++;
|
|
if (peer.resetAttempts > RESET_MAX_ATTEMPTS) {
|
|
toast(`Connection to ${peer.name} keeps failing — try leaving and rejoining the channel`, 'error');
|
|
captureRumIssue('WebRTC peer connection could not be rebuilt', 'ERROR', {
|
|
peerId: peer.id,
|
|
resetAttempts: peer.resetAttempts,
|
|
});
|
|
return;
|
|
}
|
|
send({ type: 'reset', to: peer.id });
|
|
}
|
|
if (peer.pc) { try { peer.pc.close(); } catch {} }
|
|
if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; }
|
|
peer.pc = null;
|
|
peer.makingOffer = false;
|
|
peer.ignoreOffer = false;
|
|
peer.pendingCandidates = [];
|
|
detachSpatial(peer);
|
|
if (peer.audioEl) { try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {} peer.audioEl = null; }
|
|
peer.micStream = null;
|
|
peer.screenStreams.clear();
|
|
S.screenSenders.delete(peer.id);
|
|
renderScreens();
|
|
renderTiles();
|
|
ensurePC(peer, S.myId < peer.id); // same deterministic-initiator rule as the first connection
|
|
}
|
|
|
|
async function onDescription(peer, desc) {
|
|
const pc = peer.pc;
|
|
const collision = desc.type === 'offer' && (peer.makingOffer || pc.signalingState !== 'stable');
|
|
peer.ignoreOffer = !peer.polite && collision;
|
|
if (peer.ignoreOffer) return; // impolite side ignores the rival offer
|
|
|
|
await pc.setRemoteDescription(desc); // modern browsers roll back implicitly when polite
|
|
for (const c of peer.pendingCandidates.splice(0)) {
|
|
await pc.addIceCandidate(c).catch((e) => {
|
|
console.warn('buffered candidate failed', e);
|
|
captureRumIssue('WebRTC buffered ICE candidate failed', 'WARNING', { peerId: peer.id, error: e?.message || String(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; }
|
|
if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; }
|
|
detachSpatial(peer);
|
|
if (peer.audioEl) {
|
|
try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {}
|
|
peer.audioEl = null;
|
|
}
|
|
peer.micStream = null;
|
|
peer.screenStreams.clear();
|
|
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') {
|
|
const isNewScreenShare = !peer.screenStreams.has(stream.id);
|
|
peer.screenStreams.set(stream.id, stream);
|
|
track.addEventListener('ended', () => {
|
|
if (peer.screenStreams.get(stream.id) === stream) {
|
|
peer.screenStreams.delete(stream.id);
|
|
if (!peer.screenStreams.size) {
|
|
peer.state = { ...peer.state, sharing: false };
|
|
captureRumIssue('Remote screen share ended', 'INFO', {
|
|
peerId: peer.id,
|
|
peerName: peer.name,
|
|
streamId: stream.id,
|
|
});
|
|
}
|
|
renderScreens();
|
|
renderTiles();
|
|
}
|
|
});
|
|
stream.addEventListener('inactive', () => {
|
|
if (peer.screenStreams.get(stream.id) === stream) {
|
|
peer.screenStreams.delete(stream.id);
|
|
peer.state = { ...peer.state, sharing: false };
|
|
renderScreens();
|
|
renderTiles();
|
|
}
|
|
}, { once: true });
|
|
renderScreens();
|
|
if (isNewScreenShare) playRetroCue('share');
|
|
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.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);
|
|
captureRumIssue('WebRTC display media capture failed', 'ERROR', { error: fallbackErr?.message || String(fallbackErr) });
|
|
toast(`Could not share screen: ${fallbackErr.message || fallbackErr}`, 'error');
|
|
return;
|
|
}
|
|
}
|
|
S.screenStreams.set(stream.id, stream);
|
|
S.sharing = true;
|
|
playRetroCue('share');
|
|
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) {
|
|
const screenHint = localStorage.getItem(LS_SCREEN_HINT) || '';
|
|
if ('contentHint' in videoTrack) videoTrack.contentHint = screenHint;
|
|
videoTrack.addEventListener('ended', () => stopScreenShare(stream));
|
|
}
|
|
renderScreens();
|
|
// Open a desktop preview automatically; mobile keeps the in-page preview.
|
|
if (window.matchMedia('(min-width: 700px)').matches) {
|
|
popOutShare({ key: `me:${stream.id}`, name: `${S.name} (you)`, stream, local: true });
|
|
}
|
|
renderTiles();
|
|
for (const btn of shareButtons) btn.classList.add('active');
|
|
toast('You are sharing your screen', 'ok');
|
|
}
|
|
|
|
function addScreenToPeer(peer) {
|
|
if (!peer.pc || !S.sharing) return;
|
|
const peerSenders = new Map();
|
|
for (const [streamId, stream] of S.screenStreams) {
|
|
const senders = [];
|
|
for (const track of stream.getTracks()) {
|
|
senders.push(peer.pc.addTrack(track, stream));
|
|
}
|
|
peerSenders.set(streamId, senders);
|
|
}
|
|
S.screenSenders.set(peer.id, peerSenders); // renegotiation fires automatically
|
|
}
|
|
|
|
function stopScreenShare(stream, silent) {
|
|
const streams = stream ? [stream] : [...S.screenStreams.values()];
|
|
if (!streams.length) return;
|
|
for (const [peerId, peerSenders] of S.screenSenders) {
|
|
const peer = S.peers.get(peerId);
|
|
if (!peer || !peer.pc) continue;
|
|
for (const localStream of streams) {
|
|
for (const sender of peerSenders.get(localStream.id) || []) {
|
|
try { peer.pc.removeTrack(sender); } catch {}
|
|
}
|
|
peerSenders.delete(localStream.id);
|
|
}
|
|
}
|
|
for (const localStream of streams) {
|
|
if (S.screenStreams.get(localStream.id) !== localStream) continue;
|
|
localStream.getTracks().forEach((t) => t.stop());
|
|
S.screenStreams.delete(localStream.id);
|
|
}
|
|
S.sharing = S.screenStreams.size > 0;
|
|
if (!S.sharing) S.screenSenders.clear();
|
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: S.sharing } });
|
|
renderScreens();
|
|
renderTiles();
|
|
for (const btn of shareButtons) btn.classList.toggle('active', S.sharing);
|
|
if (!silent) toast(S.sharing ? 'Screen share stopped' : 'Screen sharing stopped');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// position sync
|
|
// ---------------------------------------------------------------------------
|
|
// Fans new joiners out from directly in front of the room's center: the
|
|
// first joiner lands straight ahead, then each next one alternates sides,
|
|
// widening by 10deg per pair (0, +10, -10, +20, -20, ...) instead of the
|
|
// old distance-maximizing grid search, which tended to dump joiners in a
|
|
// far corner rather than somewhere natural to face the group.
|
|
function chooseOpenParticipantPosition() {
|
|
const ANCHOR = { x: 50, y: 50 };
|
|
const RADIUS = 24;
|
|
const n = S.peers.size;
|
|
const step = Math.ceil(n / 2) * 10;
|
|
const angleDeg = n === 0 ? 0 : (n % 2 === 1 ? step : -step);
|
|
const rad = (angleDeg * Math.PI) / 180;
|
|
return {
|
|
x: ANCHOR.x + RADIUS * Math.sin(rad),
|
|
y: ANCHOR.y - RADIUS * Math.cos(rad),
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function updateMicSignal(level = S.audioManager.getLevel()) {
|
|
const meter = $('#mic-level');
|
|
const value = $('#mic-level-value');
|
|
const clipping = $('#mic-clipping');
|
|
if (meter) meter.value = Math.min(1, level.rms * 3);
|
|
if (value) value.textContent = level.rms > 0 ? `${Math.round(20 * Math.log10(level.rms))} dB` : '-∞ dB';
|
|
if (clipping) clipping.classList.toggle('hidden', !level.clipping);
|
|
}
|
|
|
|
setInterval(() => {
|
|
if (!S.audioCtx) return;
|
|
const localLevel = S.audioManager.getLevel();
|
|
updateMicSignal(localLevel);
|
|
const meSpeaking = S.micStream && !S.muted && localLevel.rms > 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) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge-mute';
|
|
b.title = 'Muted';
|
|
b.appendChild(mIcon('mic_off'));
|
|
meBadges.appendChild(b);
|
|
}
|
|
if (S.deafened) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge-deafen';
|
|
b.title = 'Deafened';
|
|
b.appendChild(mIcon('headset_off'));
|
|
meBadges.appendChild(b);
|
|
}
|
|
if (S.sharing) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge-share';
|
|
b.title = 'Sharing screen';
|
|
b.appendChild(mIcon('present_to_all'));
|
|
meBadges.appendChild(b);
|
|
}
|
|
|
|
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) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge-mute';
|
|
b.title = 'Muted';
|
|
b.appendChild(mIcon('mic_off'));
|
|
badges.appendChild(b);
|
|
}
|
|
if (peer.state.sharing) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge-share';
|
|
b.title = 'Sharing screen';
|
|
b.appendChild(mIcon('present_to_all'));
|
|
badges.appendChild(b);
|
|
}
|
|
|
|
// 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) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge-mute';
|
|
b.title = 'Muted';
|
|
b.appendChild(mIcon('mic_off'));
|
|
badges.appendChild(b);
|
|
}
|
|
if (user.state?.sharing) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge-share';
|
|
b.title = 'Sharing screen';
|
|
b.appendChild(mIcon('present_to_all'));
|
|
badges.appendChild(b);
|
|
}
|
|
|
|
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}
|
|
let focusedShareKey = null; // clicked remote share, rendered large with the rest as a filmstrip
|
|
|
|
function activeShares() {
|
|
const shares = [];
|
|
for (const [streamId, stream] of S.screenStreams) {
|
|
shares.push({ key: `me:${streamId}`, name: `${S.name} (you)`, stream, local: true });
|
|
}
|
|
for (const peer of S.peers.values()) {
|
|
if (peer.state.sharing) {
|
|
for (const stream of peer.screenStreams.values()) {
|
|
shares.push({ key: `${peer.id}:${stream.id}`, name: peer.name, stream });
|
|
}
|
|
}
|
|
}
|
|
return shares;
|
|
}
|
|
|
|
// Picks the column/row count (out of `count` tiles) that best fills a
|
|
// width x height area assuming ~16:9 tiles, so multiple streams can be
|
|
// watched at once with as little wasted space as possible.
|
|
function bestGrid(count, width, height) {
|
|
const aspect = 16 / 9;
|
|
let best = { cols: count, rows: 1, area: 0 };
|
|
for (let cols = 1; cols <= count; cols++) {
|
|
const rows = Math.ceil(count / cols);
|
|
let cellW = width / cols;
|
|
let cellH = height / rows;
|
|
if (cellW / cellH > aspect) cellW = cellH * aspect; else cellH = cellW / aspect;
|
|
const area = cellW * cellH;
|
|
if (area > best.area) best = { cols, rows, area };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// Re-measures the live grid and updates its column/row count only — avoids
|
|
// renderScreens()'s full teardown, which would restart <video> playback.
|
|
function applyGridLayout() {
|
|
const body = els.screens?.querySelector('.screens-grid');
|
|
const count = body?.children.length;
|
|
if (!body || !count) return;
|
|
const rect = body.getBoundingClientRect();
|
|
const { cols, rows } = bestGrid(count, rect.width || 800, rect.height || 450);
|
|
body.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
|
body.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
|
|
}
|
|
|
|
let screensResizeObserver = null;
|
|
function ensureScreensResizeObserver() {
|
|
if (screensResizeObserver || !els.screens || typeof ResizeObserver === 'undefined') return;
|
|
let raf = null;
|
|
screensResizeObserver = new ResizeObserver(() => {
|
|
if (raf) return;
|
|
raf = requestAnimationFrame(() => { raf = null; applyGridLayout(); });
|
|
});
|
|
screensResizeObserver.observe(els.screens);
|
|
}
|
|
|
|
function buildPoppedPlaceholder(share) {
|
|
const ph = document.createElement('div');
|
|
ph.className = 'screen-card popped';
|
|
ph.dataset.shareKey = share.key;
|
|
const btn = document.createElement('button');
|
|
btn.className = 'pop-btn';
|
|
btn.appendChild(mIcon('open_in_new'));
|
|
btn.appendChild(document.createTextNode(' 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);
|
|
return ph;
|
|
}
|
|
|
|
function buildShareCard(share, focused) {
|
|
if (poppedWindows.has(share.key)) return buildPoppedPlaceholder(share);
|
|
const card = document.createElement('div');
|
|
card.className = 'screen-card' + (focused ? ' focused' : '');
|
|
card.dataset.shareKey = share.key;
|
|
card.title = focused ? 'Click to exit spotlight' : 'Click to watch this stream large';
|
|
const video = document.createElement('video');
|
|
video.autoplay = true;
|
|
video.playsInline = true;
|
|
video.muted = share.local;
|
|
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';
|
|
micBadge.appendChild(mIcon('mic_off'));
|
|
label.appendChild(micBadge);
|
|
const pop = document.createElement('button');
|
|
pop.className = 'pop-btn';
|
|
pop.title = 'Pop out in new window';
|
|
pop.appendChild(mIcon('open_in_new'));
|
|
pop.onclick = (e) => { e.stopPropagation(); popOutShare(share); };
|
|
card.append(video, label, pop);
|
|
card.addEventListener('click', (e) => {
|
|
if (e.target.closest('button')) return;
|
|
focusedShareKey = focusedShareKey === share.key ? null : share.key;
|
|
renderScreens();
|
|
});
|
|
return card;
|
|
}
|
|
|
|
function buildOwnPill(share) {
|
|
const pill = document.createElement('div');
|
|
pill.className = 'own-pill';
|
|
pill.dataset.shareKey = share.key;
|
|
const dot = document.createElement('span');
|
|
dot.className = 'live';
|
|
dot.innerHTML = '●';
|
|
const label = document.createElement('span');
|
|
label.className = 'own-pill-label';
|
|
label.textContent = 'You are sharing';
|
|
const stop = document.createElement('button');
|
|
stop.className = 'own-pill-close';
|
|
stop.title = 'Stop screen share';
|
|
stop.appendChild(mIcon('close'));
|
|
stop.onclick = () => stopScreenShare(share.stream);
|
|
pill.append(dot, label, stop);
|
|
return pill;
|
|
}
|
|
|
|
function renderScreens() {
|
|
const shares = activeShares();
|
|
// prune popouts whose share ended
|
|
const live = new Set(shares.map((s) => s.key));
|
|
for (const [key, p] of [...poppedWindows]) {
|
|
if (!live.has(key)) {
|
|
try { p.win.close(); } catch {}
|
|
poppedWindows.delete(key);
|
|
}
|
|
}
|
|
if (focusedShareKey && !live.has(focusedShareKey)) focusedShareKey = null;
|
|
|
|
if (!els.screens) return;
|
|
|
|
const ownShares = shares.filter((s) => s.local);
|
|
const remoteShares = shares.filter((s) => !s.local);
|
|
|
|
els.screens.innerHTML = '';
|
|
|
|
if (ownShares.length) {
|
|
const pillRow = document.createElement('div');
|
|
pillRow.className = 'own-pills';
|
|
for (const share of ownShares) pillRow.appendChild(buildOwnPill(share));
|
|
els.screens.appendChild(pillRow);
|
|
}
|
|
|
|
if (remoteShares.length) {
|
|
const body = document.createElement('div');
|
|
if (focusedShareKey) {
|
|
body.className = 'screens-body spotlight';
|
|
const focused = remoteShares.find((s) => s.key === focusedShareKey);
|
|
const rest = remoteShares.filter((s) => s.key !== focusedShareKey);
|
|
body.appendChild(buildShareCard(focused, true));
|
|
if (rest.length) {
|
|
const strip = document.createElement('div');
|
|
strip.className = 'thumbs-row';
|
|
for (const s of rest) strip.appendChild(buildShareCard(s, false));
|
|
body.appendChild(strip);
|
|
}
|
|
els.screens.appendChild(body);
|
|
} else {
|
|
body.className = 'screens-body screens-grid';
|
|
for (const s of remoteShares) body.appendChild(buildShareCard(s, false));
|
|
els.screens.appendChild(body);
|
|
applyGridLayout();
|
|
ensureScreensResizeObserver();
|
|
}
|
|
}
|
|
|
|
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.startsWith('me:')) {
|
|
speaking = els.meAvatar.classList.contains('speaking');
|
|
muted = S.muted;
|
|
} else {
|
|
const peer = S.peers.get(key.split(':', 1)[0]);
|
|
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.local;
|
|
v.srcObject = share.stream;
|
|
v.play().catch(() => {});
|
|
poppedWindows.set(share.key, { win, name: share.name });
|
|
win.addEventListener('beforeunload', () => {
|
|
poppedWindows.delete(share.key);
|
|
renderScreens();
|
|
});
|
|
renderScreens();
|
|
}
|
|
|
|
function closePopout(key) {
|
|
const p = poppedWindows.get(key);
|
|
if (p) {
|
|
poppedWindows.delete(key);
|
|
try { p.win.close(); } catch {}
|
|
renderScreens();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// spatial canvas (Cytoscape.js) — graph visualization with auto-zoom-to-bounds,
|
|
// smooth node dragging, distance indicators, and speaking/hover highlights.
|
|
// ---------------------------------------------------------------------------
|
|
const cy = cytoscape({
|
|
container: els.spatial,
|
|
boxSelectionEnabled: false,
|
|
autounselectify: false,
|
|
userZoomingEnabled: true,
|
|
userPanningEnabled: true,
|
|
minZoom: 0.2,
|
|
maxZoom: 3.5,
|
|
style: [
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'shape': 'ellipse',
|
|
'width': 34,
|
|
'height': 34,
|
|
'background-color': 'data(color)',
|
|
'label': 'data(label)',
|
|
'text-valign': 'bottom',
|
|
'text-margin-y': 6,
|
|
'font-size': '11px',
|
|
'font-family': 'system-ui, -apple-system, sans-serif',
|
|
'color': '#80848e',
|
|
'text-background-opacity': 0.7,
|
|
'text-background-color': '#1e1f22',
|
|
'text-background-padding': '2px',
|
|
'text-background-shape': 'roundrectangle',
|
|
'border-width': 2,
|
|
'border-color': 'rgba(255,255,255,0.15)',
|
|
'transition-property': 'border-color, border-width, width, height',
|
|
'transition-duration': '0.15s',
|
|
},
|
|
},
|
|
{
|
|
selector: 'node[?isMe]',
|
|
style: {
|
|
'width': 40,
|
|
'height': 40,
|
|
'color': '#b5bac1',
|
|
'font-weight': 'bold',
|
|
'border-width': 3,
|
|
'border-color': 'rgba(255,255,255,0.4)',
|
|
},
|
|
},
|
|
{
|
|
selector: 'node.speaking',
|
|
style: {
|
|
'border-color': '#23a55a',
|
|
'border-width': 4,
|
|
},
|
|
},
|
|
{
|
|
selector: 'node.muted',
|
|
style: {
|
|
'border-color': '#f23f43',
|
|
'border-width': 3,
|
|
},
|
|
},
|
|
{
|
|
selector: 'node:selected',
|
|
style: {
|
|
'border-color': '#5865f2',
|
|
'border-width': 4,
|
|
},
|
|
},
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'width': 1,
|
|
'line-color': 'rgba(88, 101, 242, 0.25)',
|
|
'line-style': 'dashed',
|
|
'curve-style': 'bezier',
|
|
'label': 'data(label)',
|
|
'font-size': '9px',
|
|
'color': '#80848e',
|
|
'text-background-opacity': 0.6,
|
|
'text-background-color': '#111214',
|
|
'text-background-padding': '2px',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
let draggedNodeId = null; // set explicitly in grab/free — not re-derived via ':grabbed' each tick
|
|
|
|
// Convert spatial room units (0..100) to Cytoscape viewport pixels (scaled around center)
|
|
const SPATIAL_PX_SCALE = 6;
|
|
function roomToCyPos(x, y) {
|
|
return { x: (x - 50) * SPATIAL_PX_SCALE, y: (y - 50) * SPATIAL_PX_SCALE };
|
|
}
|
|
function cyToRoomPos(pos) {
|
|
const x = Math.min(ROOM_UNITS, Math.max(0, pos.x / SPATIAL_PX_SCALE + 50));
|
|
const y = Math.min(ROOM_UNITS, Math.max(0, pos.y / SPATIAL_PX_SCALE + 50));
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return null; // never let NaN/Infinity leak into S.me/peer.x/y
|
|
return { x, y };
|
|
}
|
|
|
|
// Fit / zoom viewport to all active users with comfortable padding
|
|
function zoomToBounds(animate = true) {
|
|
const nodes = cy.nodes();
|
|
if (nodes.length === 0) return;
|
|
if (animate) {
|
|
cy.stop();
|
|
cy.animate({
|
|
fit: { eles: nodes, padding: 60 },
|
|
duration: 350,
|
|
easing: 'ease-out-cubic',
|
|
});
|
|
} else {
|
|
cy.fit(nodes, 60);
|
|
}
|
|
}
|
|
|
|
// Handle dragging nodes
|
|
cy.on('grab', 'node', (evt) => {
|
|
cy.stop(); // cancel any in-flight fit animation so it can't fight the drag's pointer->model mapping
|
|
draggedNodeId = evt.target.id();
|
|
if (!evt.target.data('isMe')) {
|
|
toast(`Repositioning ${evt.target.data('label')} — this only changes how loud/where YOU hear them, not their real position`, 'warn');
|
|
}
|
|
els.spatial.classList.add('dragging');
|
|
});
|
|
|
|
cy.on('drag', 'node', (evt) => {
|
|
const node = evt.target;
|
|
const pos = cyToRoomPos(node.position());
|
|
if (!pos) return; // guard: never propagate a bad (NaN/Infinity) position into shared state
|
|
// cyToRoomPos already clamped pos to the room — snap the node's actual on-screen
|
|
// position back to that clamp too. Without this, Cytoscape lets the dot follow the
|
|
// pointer arbitrarily far past the edge (nothing was clamping node.position() itself,
|
|
// only the derived room coordinate), so it visually flew off while the stored
|
|
// position silently stayed pinned at the wall — then snapped back the instant you
|
|
// let go. This keeps everyone within the room, which given refDistance=4 /
|
|
// rolloffFactor=1.4 (see updatePanner) always keeps audio gain above ~18%, i.e.
|
|
// nobody can drag themselves or a peer out of hearing range.
|
|
node.position(roomToCyPos(pos.x, pos.y));
|
|
if (node.data('isMe')) {
|
|
S.me.x = pos.x;
|
|
S.me.y = pos.y;
|
|
sendMyPos();
|
|
} else {
|
|
// Peer dragged: update local audio position relative to me — never sent to anyone else
|
|
const peer = S.peers.get(node.id());
|
|
if (peer) {
|
|
peer.x = pos.x;
|
|
peer.y = pos.y;
|
|
updatePanner(peer);
|
|
}
|
|
}
|
|
updateSpatialEdges();
|
|
});
|
|
|
|
cy.on('free', 'node', (evt) => {
|
|
draggedNodeId = null;
|
|
els.spatial.classList.remove('dragging');
|
|
const node = evt.target;
|
|
if (node.data('isMe')) {
|
|
sendMyPos(true);
|
|
}
|
|
});
|
|
|
|
// Update connecting audio distance edges between 'me' and peers
|
|
function updateSpatialEdges() {
|
|
const meNode = cy.getElementById('me');
|
|
if (!meNode || meNode.empty()) return;
|
|
const mePos = meNode.position();
|
|
|
|
for (const peer of S.peers.values()) {
|
|
const peerNode = cy.getElementById(peer.id);
|
|
if (!peerNode || peerNode.empty()) continue;
|
|
const edgeId = `edge-me-${peer.id}`;
|
|
let edge = cy.getElementById(edgeId);
|
|
|
|
// Calculate 2D distance
|
|
const dist = Math.hypot(peer.x - S.me.x, peer.y - S.me.y);
|
|
const distText = `${Math.round(dist)}m`;
|
|
|
|
if (edge.empty()) {
|
|
cy.add({
|
|
group: 'edges',
|
|
data: { id: edgeId, source: 'me', target: peer.id, label: distText },
|
|
});
|
|
} else {
|
|
edge.data('label', distText);
|
|
}
|
|
}
|
|
|
|
// Remove orphan edges
|
|
cy.edges().forEach((edge) => {
|
|
const peerId = edge.data('target');
|
|
if (!S.peers.has(peerId)) {
|
|
edge.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
let lastPeerCount = -1;
|
|
|
|
function syncSpatialGraph() {
|
|
const meId = S.myId || 'me';
|
|
const meSpeaking = els.meAvatar.classList.contains('speaking');
|
|
|
|
// Upsert 'me' node
|
|
let meNode = cy.getElementById('me');
|
|
if (meNode.empty()) {
|
|
meNode = cy.add({
|
|
group: 'nodes',
|
|
data: {
|
|
id: 'me',
|
|
label: `${S.name || 'me'} (you)`,
|
|
color: colorFor(meId),
|
|
isMe: true,
|
|
},
|
|
position: roomToCyPos(S.me.x, S.me.y),
|
|
});
|
|
} else {
|
|
meNode.data('label', `${S.name || 'me'} (you)`);
|
|
meNode.data('color', colorFor(meId));
|
|
if (draggedNodeId !== 'me') {
|
|
meNode.position(roomToCyPos(S.me.x, S.me.y));
|
|
}
|
|
}
|
|
meNode.toggleClass('speaking', meSpeaking);
|
|
meNode.toggleClass('muted', !!S.muted);
|
|
|
|
// Upsert peers
|
|
const currentPeerIds = new Set();
|
|
for (const peer of S.peers.values()) {
|
|
currentPeerIds.add(peer.id);
|
|
let peerNode = cy.getElementById(peer.id);
|
|
if (peerNode.empty()) {
|
|
peerNode = cy.add({
|
|
group: 'nodes',
|
|
data: {
|
|
id: peer.id,
|
|
label: peer.name,
|
|
color: colorFor(peer.id),
|
|
isMe: false,
|
|
},
|
|
position: roomToCyPos(peer.x, peer.y),
|
|
});
|
|
} else {
|
|
peerNode.data('label', peer.name);
|
|
peerNode.data('color', colorFor(peer.id));
|
|
if (draggedNodeId !== peer.id) {
|
|
peerNode.position(roomToCyPos(peer.x, peer.y));
|
|
}
|
|
}
|
|
peerNode.toggleClass('speaking', !!peer.speaking);
|
|
peerNode.toggleClass('muted', !!peer.state?.muted);
|
|
}
|
|
|
|
// Remove nodes that have left
|
|
cy.nodes().forEach((node) => {
|
|
const id = node.id();
|
|
if (id !== 'me' && !currentPeerIds.has(id)) {
|
|
node.remove();
|
|
}
|
|
});
|
|
|
|
updateSpatialEdges();
|
|
|
|
// Auto-fit / zoom to bounds whenever participants count changes
|
|
const totalCount = S.peers.size + 1;
|
|
if (totalCount !== lastPeerCount) {
|
|
lastPeerCount = totalCount;
|
|
setTimeout(() => zoomToBounds(true), 50);
|
|
}
|
|
}
|
|
|
|
// Auto resize on container size change
|
|
new ResizeObserver(() => {
|
|
cy.resize();
|
|
zoomToBounds(false);
|
|
}).observe(els.spatial);
|
|
|
|
$('#btn-spatial-fit')?.addEventListener('click', () => zoomToBounds(true));
|
|
|
|
setInterval(syncSpatialGraph, 100);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Matrix text chat (experimental) — native OAuth2/OIDC auth against the
|
|
// homeserver's own authorization server (MSC2965/MSC3861), so there's no
|
|
// separate Matrix password: login redirects to matrix.k6n.net itself.
|
|
// No E2E crypto — rooms are plaintext, matching this app's POC posture.
|
|
// ---------------------------------------------------------------------------
|
|
const MX_BASE_URL = 'https://matrix.k6n.net';
|
|
const MX_SERVER_NAME = 'matrix.k6n.net';
|
|
const LS_MX_CLIENT_ID = 'voicepoc.mxClientId';
|
|
const LS_MX_SESSION = 'voicepoc.mxSession';
|
|
const SS_MX_PENDING = 'voicepoc.mxOidcPending';
|
|
|
|
const Mx = { client: null, roomId: null };
|
|
|
|
function mxRedirectUri() {
|
|
return location.origin + location.pathname;
|
|
}
|
|
|
|
async function mxRegisterClientId(authMetadata) {
|
|
const cached = localStorage.getItem(LS_MX_CLIENT_ID);
|
|
if (cached) return cached;
|
|
const clientId = await matrixcs.OAuth2.registerClient(authMetadata, {
|
|
client_name: 'gibbz voice',
|
|
client_uri: mxRedirectUri(),
|
|
redirect_uris: [mxRedirectUri()],
|
|
application_type: 'web',
|
|
});
|
|
localStorage.setItem(LS_MX_CLIENT_ID, clientId);
|
|
return clientId;
|
|
}
|
|
|
|
async function mxStartLogin() {
|
|
try {
|
|
const bare = matrixcs.createClient({ baseUrl: MX_BASE_URL });
|
|
const authMetadata = await bare.getAuthMetadata();
|
|
const clientId = await mxRegisterClientId(authMetadata);
|
|
const oauth = new matrixcs.OAuth2(authMetadata, { clientId, redirectUri: mxRedirectUri() });
|
|
const state = crypto.randomUUID();
|
|
sessionStorage.setItem(SS_MX_PENDING, JSON.stringify({
|
|
state,
|
|
authMetadata,
|
|
clientId,
|
|
deviceId: oauth.context.deviceId,
|
|
codeVerifier: oauth.context.codeVerifier,
|
|
redirectUri: oauth.context.redirectUri,
|
|
}));
|
|
const url = await oauth.generateAuthorizationCodeGrantUrl(state, 'query');
|
|
window.location.href = url;
|
|
} catch (err) {
|
|
console.error('matrix login failed to start', err);
|
|
toast(`Matrix login failed: ${err.message || err}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function mxCompleteLogin(code, state) {
|
|
const raw = sessionStorage.getItem(SS_MX_PENDING);
|
|
sessionStorage.removeItem(SS_MX_PENDING);
|
|
if (!raw) { toast('Matrix login callback received but no pending login found', 'error'); return; }
|
|
const pending = JSON.parse(raw);
|
|
if (pending.state !== state) { toast('Matrix login state mismatch — try again', 'error'); return; }
|
|
try {
|
|
const oauth = new matrixcs.OAuth2(pending.authMetadata, {
|
|
clientId: pending.clientId,
|
|
deviceId: pending.deviceId,
|
|
codeVerifier: pending.codeVerifier,
|
|
redirectUri: pending.redirectUri,
|
|
});
|
|
const tokens = await oauth.completeAuthorizationCodeGrant(code);
|
|
await mxAdoptTokens(tokens);
|
|
toast('Matrix connected', 'ok');
|
|
mxShowPanel();
|
|
} catch (err) {
|
|
console.error('matrix login completion failed', err);
|
|
toast(`Matrix login failed: ${err.message || err}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function mxAdoptTokens(tokens) {
|
|
const probe = matrixcs.createClient({ baseUrl: MX_BASE_URL, accessToken: tokens.access_token });
|
|
const who = await probe.whoami();
|
|
const session = {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || null,
|
|
userId: who.user_id,
|
|
deviceId: who.device_id,
|
|
};
|
|
localStorage.setItem(LS_MX_SESSION, JSON.stringify(session));
|
|
await mxBoot(session);
|
|
}
|
|
|
|
async function mxBoot(session) {
|
|
Mx.client = matrixcs.createClient({
|
|
baseUrl: MX_BASE_URL,
|
|
accessToken: session.accessToken,
|
|
refreshToken: session.refreshToken || undefined,
|
|
userId: session.userId,
|
|
deviceId: session.deviceId,
|
|
fallbackICEServerAllowed: false,
|
|
});
|
|
Mx.client.on(matrixcs.ClientEvent.Sync, (state) => {
|
|
if (state === 'PREPARED') mxOnReady();
|
|
});
|
|
Mx.client.on(matrixcs.RoomEvent.Timeline, (event, room) => {
|
|
if (room && room.roomId === Mx.roomId && event.getType() === 'm.room.message') mxRenderTimeline();
|
|
});
|
|
await Mx.client.startClient({ initialSyncLimit: 30 });
|
|
}
|
|
|
|
async function mxOnReady() {
|
|
try {
|
|
Mx.roomId = await mxJoinOrCreateRoom('gibbz-general', 'gibbz general');
|
|
mxRenderTimeline();
|
|
} catch (err) {
|
|
console.error('matrix room join/create failed', err);
|
|
toast(`Could not open #general text channel: ${err.message || err}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function mxJoinOrCreateRoom(aliasLocalPart, name) {
|
|
const alias = `#${aliasLocalPart}:${MX_SERVER_NAME}`;
|
|
try {
|
|
const res = await Mx.client.joinRoom(alias);
|
|
return res.roomId;
|
|
} catch (err) {
|
|
if (err.errcode !== 'M_NOT_FOUND') throw err;
|
|
const created = await Mx.client.createRoom({
|
|
room_alias_name: aliasLocalPart,
|
|
name,
|
|
visibility: 'public',
|
|
preset: 'public_chat',
|
|
});
|
|
return created.room_id;
|
|
}
|
|
}
|
|
|
|
function mxRenderTimeline() {
|
|
if (!Mx.client || !Mx.roomId || !els.mxMessages) return;
|
|
const room = Mx.client.getRoom(Mx.roomId);
|
|
if (!room) return;
|
|
const events = room.getLiveTimeline().getEvents().filter((e) => e.getType() === 'm.room.message');
|
|
els.mxMessages.innerHTML = '';
|
|
for (const ev of events) {
|
|
const row = document.createElement('div');
|
|
row.className = 'mx-msg';
|
|
const sender = document.createElement('span');
|
|
sender.className = 'mx-sender';
|
|
sender.textContent = (ev.sender && ev.sender.name) || ev.getSender();
|
|
const body = document.createElement('span');
|
|
body.className = 'mx-body';
|
|
body.textContent = ev.getContent().body || '';
|
|
row.append(sender, body);
|
|
els.mxMessages.appendChild(row);
|
|
}
|
|
els.mxMessages.scrollTop = els.mxMessages.scrollHeight;
|
|
}
|
|
|
|
function switchToChatView() {
|
|
if (els.voiceView) els.voiceView.classList.add('hidden');
|
|
if (els.mxPanel) els.mxPanel.classList.remove('hidden');
|
|
document.querySelectorAll('.channel.text').forEach((c) => c.classList.add('active'));
|
|
if (els.mxMessages) els.mxMessages.scrollTop = els.mxMessages.scrollHeight;
|
|
if (els.mxInput) els.mxInput.focus();
|
|
}
|
|
|
|
function switchToVoiceView() {
|
|
if (els.mxPanel) els.mxPanel.classList.add('hidden');
|
|
if (els.voiceView) els.voiceView.classList.remove('hidden');
|
|
document.querySelectorAll('.channel.text').forEach((c) => c.classList.remove('active'));
|
|
// Trigger Cytoscape redraw/resize because voice-view was unhidden
|
|
if (typeof cy !== 'undefined') cy.resize();
|
|
}
|
|
|
|
function mxShowPanel() {
|
|
switchToChatView();
|
|
}
|
|
|
|
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 = () => {
|
|
closeSidebar();
|
|
if (Mx.roomId) { switchToChatView(); return; }
|
|
if (Mx.client) { toast('Connecting to Matrix…'); return; }
|
|
mxStartLogin();
|
|
};
|
|
});
|
|
$('#mx-close')?.addEventListener('click', () => switchToVoiceView());
|
|
$('#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.replaceChildren(mIcon(off ? 'mic_off' : 'mic'));
|
|
btn.disabled = !S.micStream;
|
|
}
|
|
}
|
|
function updateDeafenButton() {
|
|
for (const btn of deafenButtons) {
|
|
btn.classList.toggle('off', S.deafened);
|
|
btn.replaceChildren(mIcon(S.deafened ? 'headset_off' : 'headphones'));
|
|
}
|
|
}
|
|
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 = startScreenShare;
|
|
$('#btn-settings').onclick = openSettings;
|
|
|
|
els.nick.value = S.name;
|
|
els.nick.addEventListener('change', () => {
|
|
const v = els.nick.value.trim();
|
|
if (v) S.name = v;
|
|
els.nick.value = S.name;
|
|
localStorage.setItem(LS_NAME, S.name);
|
|
setAvatar(els.meAvatar, S.name, S.myId || 'me');
|
|
if (S.ws && S.ws.readyState === 1) send({ type: 'rename', name: S.name });
|
|
renderTiles();
|
|
});
|
|
els.nick.addEventListener('blur', () => {
|
|
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();
|
|
switchToVoiceView();
|
|
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));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// desktop panel resizing + sidebar collapse
|
|
// ---------------------------------------------------------------------------
|
|
const LS_SIDEBAR_W = 'voicepoc.sidebarWidth';
|
|
const LS_SIDEBAR_COLLAPSED = 'voicepoc.sidebarCollapsed';
|
|
const LS_SPATIAL_H = 'voicepoc.spatialHeight';
|
|
const SIDEBAR_MIN = 180, SIDEBAR_MAX = 420;
|
|
const SPATIAL_MIN = 140, SPATIAL_MAX_RATIO = 0.75;
|
|
const isDesktop = () => window.matchMedia('(min-width: 861px)').matches;
|
|
|
|
function dragResize({ handle, axis, onMove }) {
|
|
handle.addEventListener('mousedown', (e) => {
|
|
if (!isDesktop()) return;
|
|
e.preventDefault();
|
|
handle.classList.add('dragging');
|
|
const start = axis === 'x' ? e.clientX : e.clientY;
|
|
const onMouseMove = (ev) => onMove((axis === 'x' ? ev.clientX : ev.clientY) - start);
|
|
const onMouseUp = () => {
|
|
handle.classList.remove('dragging');
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
};
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
});
|
|
}
|
|
|
|
// sidebar width
|
|
const sidebarResizer = $('#sidebar-resizer');
|
|
const savedSidebarW = parseInt(localStorage.getItem(LS_SIDEBAR_W), 10);
|
|
if (isDesktop() && savedSidebarW) els.sidebar.style.width = `${savedSidebarW}px`;
|
|
let sidebarStartW = 0;
|
|
dragResize({
|
|
handle: sidebarResizer, axis: 'x',
|
|
onMove: (dx) => {
|
|
if (!sidebarStartW) sidebarStartW = els.sidebar.getBoundingClientRect().width;
|
|
const w = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, sidebarStartW + dx));
|
|
els.sidebar.style.width = `${w}px`;
|
|
localStorage.setItem(LS_SIDEBAR_W, String(Math.round(w)));
|
|
},
|
|
});
|
|
sidebarResizer.addEventListener('mousedown', () => { sidebarStartW = 0; });
|
|
|
|
// sidebar collapse/expand
|
|
const btnSidebarCollapse = $('#btn-sidebar-collapse');
|
|
const btnSidebarExpand = $('#btn-sidebar-expand');
|
|
function setSidebarCollapsed(collapsed) {
|
|
document.body.classList.toggle('sidebar-collapsed', collapsed);
|
|
localStorage.setItem(LS_SIDEBAR_COLLAPSED, collapsed ? '1' : '0');
|
|
}
|
|
btnSidebarCollapse.onclick = () => setSidebarCollapsed(true);
|
|
btnSidebarExpand.onclick = () => setSidebarCollapsed(false);
|
|
if (isDesktop() && localStorage.getItem(LS_SIDEBAR_COLLAPSED) === '1') setSidebarCollapsed(true);
|
|
|
|
// spatial pane height
|
|
const spatialResizer = $('#spatial-resizer');
|
|
const spatialWrap = $('#spatial-wrap');
|
|
const savedSpatialH = parseInt(localStorage.getItem(LS_SPATIAL_H), 10);
|
|
if (isDesktop() && savedSpatialH) spatialWrap.style.height = `${savedSpatialH}px`;
|
|
let spatialStartH = 0;
|
|
dragResize({
|
|
handle: spatialResizer, axis: 'y',
|
|
onMove: (dy) => {
|
|
if (!spatialStartH) spatialStartH = spatialWrap.getBoundingClientRect().height;
|
|
const max = window.innerHeight * SPATIAL_MAX_RATIO;
|
|
const h = Math.min(max, Math.max(SPATIAL_MIN, spatialStartH - dy));
|
|
spatialWrap.style.height = `${h}px`;
|
|
localStorage.setItem(LS_SPATIAL_H, String(Math.round(h)));
|
|
if (typeof cy !== 'undefined') cy.resize();
|
|
},
|
|
});
|
|
spatialResizer.addEventListener('mousedown', () => { spatialStartH = 0; });
|
|
|
|
// spatial pane collapse/expand
|
|
const LS_SPATIAL_COLLAPSED = 'voicepoc.spatialCollapsed';
|
|
const btnSpatialCollapse = $('#btn-spatial-collapse');
|
|
const btnSpatialExpand = $('#btn-spatial-expand');
|
|
function setSpatialCollapsed(collapsed) {
|
|
document.body.classList.toggle('spatial-collapsed', collapsed);
|
|
localStorage.setItem(LS_SPATIAL_COLLAPSED, collapsed ? '1' : '0');
|
|
if (!collapsed && typeof cy !== 'undefined') requestAnimationFrame(() => cy.resize());
|
|
applyGridLayout();
|
|
}
|
|
btnSpatialCollapse.onclick = () => setSpatialCollapsed(true);
|
|
btnSpatialExpand.onclick = () => setSpatialCollapsed(false);
|
|
if (localStorage.getItem(LS_SPATIAL_COLLAPSED) === '1') setSpatialCollapsed(true);
|
|
|
|
// dropping below the desktop breakpoint: let mobile's own CSS take over sizing
|
|
window.addEventListener('resize', () => {
|
|
if (isDesktop()) return;
|
|
els.sidebar.style.width = '';
|
|
spatialWrap.style.height = '';
|
|
});
|
|
|
|
// 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);
|
|
|
|
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') startScreenShare();
|
|
});
|
|
window.addEventListener('beforeunload', () => { try { cancelReconnect(); leaveVoice(); } catch {} });
|
|
navigator.mediaDevices?.addEventListener('devicechange', populateAudioInputs);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// settings modal (custom STUN/TURN)
|
|
// ---------------------------------------------------------------------------
|
|
function openSettings() {
|
|
populateAudioInputs();
|
|
const gain = $('#mic-gain');
|
|
if (gain) gain.value = S.audioManager.gain;
|
|
updateMicGainUi();
|
|
const screenHint = $('#screen-hint');
|
|
if (screenHint) screenHint.value = localStorage.getItem(LS_SCREEN_HINT) || '';
|
|
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');
|
|
}
|
|
function updateMicGainUi() {
|
|
const gain = S.audioManager.gain;
|
|
const input = $('#mic-gain');
|
|
const value = $('#mic-gain-value');
|
|
if (input) input.value = gain;
|
|
if (value) value.textContent = `${Math.round(gain * 100)}%`;
|
|
}
|
|
$('#mic-gain').oninput = (ev) => {
|
|
S.audioManager.setGain(ev.target.value);
|
|
updateMicGainUi();
|
|
};
|
|
$('#screen-hint').onchange = (ev) => {
|
|
localStorage.setItem(LS_SCREEN_HINT, ev.target.value);
|
|
toast('Screen-share hint saved — applies next time you share', 'ok');
|
|
};
|
|
$('#audio-input').onchange = (ev) => switchAudioInput(ev.target.value);
|
|
$('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden');
|
|
$('#modal-backdrop').onclick = (ev) => {
|
|
if (ev.target === $('#modal-backdrop')) $('#modal-backdrop').classList.add('hidden');
|
|
};
|
|
function resetIceSettings(reload = false) {
|
|
localStorage.removeItem(LS_ICE);
|
|
$('#override-enabled').checked = false;
|
|
fetchIce().then((server) => {
|
|
$('#ice-json').value = JSON.stringify(server, null, 2);
|
|
if (reload) location.reload();
|
|
}).catch(() => {
|
|
if (reload) location.reload();
|
|
});
|
|
}
|
|
$('#ice-reset').onclick = () => resetIceSettings();
|
|
$('#btn-reset-ice-mobile').onclick = () => {
|
|
toast('Resetting saved ICE settings…', 'ok');
|
|
resetIceSettings(true);
|
|
};
|
|
$('#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);
|