- Mic button now swaps glyph (mic vs muted) and syncs immediately on join/leave/load instead of relying on a subtle CSS underline. - Spatial minimap camera now follows the local user, matching how the audio panning is already centered on them, instead of plotting absolute room coordinates stretched across whatever width the screen happens to be. - Screen-share tiles now show a speaking glow and muted badge, kept in sync on the existing audio-meter tick without tearing down <video> elements. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1369 lines
47 KiB
JavaScript
1369 lines
47 KiB
JavaScript
import * as matrixcs from 'matrix-js-sdk';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// state
|
|
// ---------------------------------------------------------------------------
|
|
const ROOM_UNITS = 100; // spatial plane is 0..100 x 0..100
|
|
const SPATIAL_SCALE = 0.12; // room units -> audio world units
|
|
const FALLBACK_ICE = { iceServers: [{ urls: ['stun:turn.k6n.net:3478', 'stun:stun.l.google.com:19302'] }] };
|
|
const LS_NAME = 'voicepoc.nick';
|
|
const LS_ICE = 'voicepoc.iceOverride';
|
|
|
|
const S = {
|
|
ws: null,
|
|
myId: null,
|
|
room: null,
|
|
name: localStorage.getItem(LS_NAME) || '',
|
|
peers: new Map(), // id -> peer record
|
|
micStream: null,
|
|
screenStream: null,
|
|
screenSenders: new Map(), // peerId -> [RTCRtpSender]
|
|
audioCtx: null,
|
|
me: { x: 50, y: 50 },
|
|
muted: false,
|
|
deafened: false,
|
|
iceServers: null, // resolved on join
|
|
sharing: false,
|
|
connecting: false,
|
|
pingTimer: null,
|
|
reconnect: { room: null, attempt: 0, timer: null, countdownTimer: null },
|
|
channelRosters: {}, // roomId -> [snapshot]
|
|
};
|
|
window.__dbg = S; // POC debug handle
|
|
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const els = {
|
|
stage: $('#stage'), screens: $('#screens'),
|
|
spatial: $('#spatial'), status: $('#voice-status'), statusDot: $('#status-dot'),
|
|
statusRow: $('#status-row'), nick: $('#nick'),
|
|
meAvatar: $('#me-avatar'), mic: $('#btn-mic'), deafen: $('#btn-deafen'),
|
|
reconnectBanner: $('#reconnect-banner'), reconnectText: $('#reconnect-text'),
|
|
mxPanel: $('#mx-panel'), mxMessages: $('#mx-messages'), mxInput: $('#mx-input'),
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------------
|
|
function toast(msg, kind = '') {
|
|
const t = document.createElement('div');
|
|
t.className = `toast ${kind}`;
|
|
t.textContent = msg;
|
|
$('#toasts').appendChild(t);
|
|
setTimeout(() => t.remove(), 5000);
|
|
}
|
|
|
|
const PEER_COLORS = ['#5865f2', '#23a55a', '#f0b232', '#eb459e', '#3ba55c', '#faa61a', '#e67e22', '#9b59b6'];
|
|
function colorFor(id) {
|
|
let h = 0;
|
|
for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
return PEER_COLORS[h % PEER_COLORS.length];
|
|
}
|
|
function setAvatar(el, name, id) {
|
|
el.style.background = colorFor(id);
|
|
el.textContent = (name || '?').slice(0, 1).toUpperCase();
|
|
}
|
|
|
|
function effectiveIce() {
|
|
const raw = localStorage.getItem(LS_ICE);
|
|
if (raw) {
|
|
try { return JSON.parse(raw); } catch { /* fall through */ }
|
|
}
|
|
return S.iceServers || FALLBACK_ICE;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// join / leave
|
|
// ---------------------------------------------------------------------------
|
|
async function joinVoice(room) {
|
|
if (S.room === room || S.connecting) return;
|
|
if (S.room) leaveVoice('switching channel');
|
|
S.connecting = true;
|
|
setStatus(`connecting to ${room}…`, 'connecting');
|
|
|
|
// Mic permission & AudioContext: request immediately on the user-click gesture
|
|
// so browsers don't block the microphone permission prompt or audio autoplay policy.
|
|
if (!S.audioCtx) S.audioCtx = new AudioContext();
|
|
await S.audioCtx.resume().catch(() => {});
|
|
orientListener();
|
|
|
|
try {
|
|
S.micStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
});
|
|
} catch {
|
|
S.micStream = null;
|
|
toast('No microphone available — joining in listen-only mode', 'warn');
|
|
}
|
|
els.mic.disabled = !S.micStream;
|
|
S.muted = false;
|
|
updateMicButton();
|
|
$('#btn-share').disabled = false;
|
|
|
|
try {
|
|
S.iceServers = await fetchIce();
|
|
} catch {
|
|
toast('Could not load ICE config from server — using fallback STUN', 'warn');
|
|
}
|
|
|
|
await openSignaling(room);
|
|
S.room = room;
|
|
S.connecting = false;
|
|
renderAll();
|
|
}
|
|
|
|
async function fetchIce() {
|
|
const headers = window.rum && typeof window.rum.traceHeaders === 'function' ? window.rum.traceHeaders() : {};
|
|
const res = await fetch('/api/ice', { headers });
|
|
if (!res.ok) throw new Error(`ice ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function fetchRooms() {
|
|
try {
|
|
const headers = window.rum && typeof window.rum.traceHeaders === 'function' ? window.rum.traceHeaders() : {};
|
|
const res = await fetch('/api/rooms', { headers });
|
|
if (res.ok) {
|
|
S.channelRosters = await res.json();
|
|
renderTiles();
|
|
renderChannelCounts();
|
|
}
|
|
} catch (err) {
|
|
console.warn('fetchRooms failed', err);
|
|
}
|
|
}
|
|
|
|
function leaveVoice(reason) {
|
|
if (!S.room && !S.ws) return;
|
|
if (S.sharing) stopScreenShare(true);
|
|
for (const peer of S.peers.values()) destroyPeer(peer);
|
|
S.peers.clear();
|
|
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
|
|
if (S.ws) { try { S.ws.close(); } catch {} S.ws = null; }
|
|
if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; }
|
|
els.mic.disabled = false;
|
|
// Keep btn-share enabled so clicking it prompts the user to join a channel
|
|
updateMicButton();
|
|
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
|
S.room = null;
|
|
S.myId = null;
|
|
S.muted = false;
|
|
S.deafened = false;
|
|
updateDeafenButton();
|
|
renderAll();
|
|
setStatus(reason || 'not connected');
|
|
fetchRooms();
|
|
}
|
|
|
|
function setStatus(text, state = '') {
|
|
if (els.status) {
|
|
// Concise display text
|
|
let shortText = text;
|
|
if (text.startsWith('voice connected')) {
|
|
shortText = S.room ? `#${S.room}` : 'Connected';
|
|
} else if (text.startsWith('connecting to')) {
|
|
shortText = 'Connecting…';
|
|
}
|
|
els.status.textContent = shortText;
|
|
}
|
|
if (els.statusRow) els.statusRow.title = text;
|
|
if (els.statusDot) {
|
|
els.statusDot.className = 'status-indicator' + (state ? ` ${state}` : '');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// reconnection (server restarts / redeploys, dropped signaling, etc.)
|
|
// ---------------------------------------------------------------------------
|
|
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000]; // caps at 15s
|
|
|
|
function showReconnectBanner(text) {
|
|
els.reconnectText.textContent = text;
|
|
els.reconnectBanner.classList.remove('hidden');
|
|
}
|
|
function hideReconnectBanner() {
|
|
els.reconnectBanner.classList.add('hidden');
|
|
}
|
|
|
|
function beginReconnect(room) {
|
|
cancelReconnect();
|
|
S.reconnect.room = room;
|
|
attemptReconnect();
|
|
}
|
|
|
|
function cancelReconnect() {
|
|
const r = S.reconnect;
|
|
clearTimeout(r.timer);
|
|
clearInterval(r.countdownTimer);
|
|
r.timer = r.countdownTimer = null;
|
|
r.room = null;
|
|
r.attempt = 0;
|
|
hideReconnectBanner();
|
|
}
|
|
|
|
function attemptReconnect() {
|
|
const r = S.reconnect;
|
|
if (!r.room) return;
|
|
const delay = RECONNECT_DELAYS[Math.min(r.attempt, RECONNECT_DELAYS.length - 1)];
|
|
r.attempt++;
|
|
let remaining = delay;
|
|
const label = () => `Connection lost — reconnecting in ${Math.ceil(remaining / 1000)}s (attempt ${r.attempt})`;
|
|
showReconnectBanner(label());
|
|
clearInterval(r.countdownTimer);
|
|
r.countdownTimer = setInterval(() => {
|
|
remaining = Math.max(0, remaining - 1000);
|
|
if (remaining > 0) showReconnectBanner(label());
|
|
}, 1000);
|
|
|
|
clearTimeout(r.timer);
|
|
r.timer = setTimeout(() => doReconnectAttempt(), delay);
|
|
}
|
|
|
|
async function doReconnectAttempt() {
|
|
const r = S.reconnect;
|
|
if (!r.room) return;
|
|
clearInterval(r.countdownTimer);
|
|
const room = r.room;
|
|
showReconnectBanner('Reconnecting…');
|
|
try {
|
|
await joinVoice(room);
|
|
r.room = null;
|
|
r.attempt = 0;
|
|
hideReconnectBanner();
|
|
toast('Reconnected', 'ok');
|
|
} catch {
|
|
attemptReconnect();
|
|
}
|
|
}
|
|
|
|
$('#reconnect-retry').onclick = () => {
|
|
clearTimeout(S.reconnect.timer);
|
|
clearInterval(S.reconnect.countdownTimer);
|
|
doReconnectAttempt();
|
|
};
|
|
$('#reconnect-dismiss').onclick = () => cancelReconnect();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// signaling
|
|
// ---------------------------------------------------------------------------
|
|
function openSignaling(room) {
|
|
return new Promise((resolve, reject) => {
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const url = `${proto}://${location.host}/ws?room=${encodeURIComponent(room)}&name=${encodeURIComponent(S.name)}`;
|
|
const ws = new WebSocket(url);
|
|
S.ws = ws;
|
|
ws.onerror = () => { S.connecting = false; setStatus('connection failed', 'error'); reject(new Error('websocket failed')); };
|
|
ws.onmessage = (ev) => {
|
|
let msg;
|
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
try { handleSignal(msg); } catch (err) { console.error('signal handler error', msg.type, err); }
|
|
};
|
|
ws.onclose = () => {
|
|
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
|
|
if (S.ws === ws && S.room) {
|
|
const room = S.room;
|
|
leaveVoice('disconnected');
|
|
beginReconnect(room);
|
|
}
|
|
};
|
|
ws.onopen = () => {
|
|
if (S.pingTimer) clearInterval(S.pingTimer);
|
|
S.pingTimer = setInterval(() => {
|
|
send({ type: 'ping' });
|
|
}, 10000);
|
|
resolve();
|
|
};
|
|
});
|
|
}
|
|
|
|
function send(msg) {
|
|
if (S.ws && S.ws.readyState === 1) S.ws.send(JSON.stringify(msg));
|
|
}
|
|
|
|
function handleSignal(msg) {
|
|
switch (msg.type) {
|
|
case 'rosters': {
|
|
S.channelRosters = msg.rooms || {};
|
|
renderTiles();
|
|
renderChannelCounts();
|
|
break;
|
|
}
|
|
case 'hello': {
|
|
S.myId = msg.id;
|
|
S.room = msg.room;
|
|
for (const p of msg.peers) {
|
|
S.peers.set(p.id, makePeerRecord(p));
|
|
// deterministic initiator: lower id offers (no glare storm on join)
|
|
ensurePC(S.peers.get(p.id), S.myId < p.id);
|
|
}
|
|
sendMyPos(true);
|
|
renderAll();
|
|
setStatus(`voice connected — ${msg.peers.length + 1} in ${msg.room}`, 'online');
|
|
toast(`Joined ${msg.room}`, 'ok');
|
|
break;
|
|
}
|
|
case 'joined':
|
|
S.peers.set(msg.peer.id, makePeerRecord(msg.peer));
|
|
renderAll();
|
|
toast(`${msg.peer.name} joined`, 'ok');
|
|
break;
|
|
case 'left': {
|
|
const peer = S.peers.get(msg.id);
|
|
if (peer) {
|
|
destroyPeer(peer);
|
|
S.peers.delete(msg.id);
|
|
renderAll();
|
|
toast(`${peer.name} left`);
|
|
}
|
|
break;
|
|
}
|
|
case 'pos': {
|
|
const peer = S.peers.get(msg.from);
|
|
if (peer) { peer.x = msg.x; peer.y = msg.y; updatePanner(peer); }
|
|
break;
|
|
}
|
|
case 'state': {
|
|
const peer = S.peers.get(msg.from);
|
|
if (peer) { peer.state = msg.state; renderTiles(); }
|
|
break;
|
|
}
|
|
case 'offer':
|
|
case 'answer': {
|
|
const peer = ensurePC(S.peers.get(msg.from) || createOrphanPeer(msg.from), false);
|
|
onDescription(peer, msg.sdp).catch((e) => console.error('description failed', e));
|
|
break;
|
|
}
|
|
case 'candidate': {
|
|
const peer = S.peers.get(msg.from);
|
|
if (!peer || !peer.pc) return; // candidates only after description path created pc
|
|
onCandidate(peer, msg.candidate).catch((e) => console.error('candidate failed', e));
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// peer records + WebRTC (perfect negotiation)
|
|
// ---------------------------------------------------------------------------
|
|
function makePeerRecord(p) {
|
|
return {
|
|
id: p.id, name: p.name, x: p.x, y: p.y,
|
|
state: p.state || { muted: false, deafened: false, sharing: false },
|
|
pc: null, polite: false, makingOffer: false, ignoreOffer: false,
|
|
pendingCandidates: [], micStream: null, screenStream: null,
|
|
gain: null, panner: null, analyser: null, vol: 1, speaking: false,
|
|
els: {},
|
|
};
|
|
}
|
|
|
|
function createOrphanPeer(id) {
|
|
const p = makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50 });
|
|
S.peers.set(id, p);
|
|
return p;
|
|
}
|
|
|
|
function ensurePC(peer, initiator) {
|
|
if (peer.pc) return peer;
|
|
const pc = new RTCPeerConnection({ ...effectiveIce(), iceCandidatePoolSize: 4 });
|
|
peer.pc = pc;
|
|
peer.polite = S.myId > peer.id;
|
|
|
|
pc.onnegotiationneeded = async () => {
|
|
try {
|
|
peer.makingOffer = true;
|
|
await pc.setLocalDescription();
|
|
send({ type: 'offer', to: peer.id, sdp: pc.localDescription });
|
|
} catch (err) {
|
|
console.error('negotiation failed', err);
|
|
} finally {
|
|
peer.makingOffer = false;
|
|
}
|
|
};
|
|
pc.onicecandidate = ({ candidate }) => {
|
|
if (candidate) send({ type: 'candidate', to: peer.id, candidate });
|
|
};
|
|
pc.onconnectionstatechange = () => {
|
|
renderTiles();
|
|
if (pc.connectionState === 'failed') {
|
|
console.warn('peer connection failed, restarting ICE', peer.id);
|
|
try { pc.restartIce(); } catch {}
|
|
}
|
|
};
|
|
pc.ontrack = (ev) => onRemoteTrack(peer, ev);
|
|
|
|
// send my mic; if I have none, ask to receive theirs
|
|
if (S.micStream) {
|
|
for (const track of S.micStream.getAudioTracks()) pc.addTrack(track, S.micStream);
|
|
} else {
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
}
|
|
|
|
// send my screen share if I am currently sharing when this peer connects
|
|
if (S.sharing && S.screenStream) {
|
|
const senders = [];
|
|
for (const track of S.screenStream.getTracks()) {
|
|
senders.push(pc.addTrack(track, S.screenStream));
|
|
}
|
|
S.screenSenders.set(peer.id, senders);
|
|
}
|
|
|
|
if (initiator) pc.onnegotiationneeded();
|
|
return peer;
|
|
}
|
|
|
|
async function onDescription(peer, desc) {
|
|
const pc = peer.pc;
|
|
const collision = desc.type === 'offer' && (peer.makingOffer || pc.signalingState !== 'stable');
|
|
peer.ignoreOffer = !peer.polite && collision;
|
|
if (peer.ignoreOffer) return; // impolite side ignores the rival offer
|
|
|
|
await pc.setRemoteDescription(desc); // modern browsers roll back implicitly when polite
|
|
for (const c of peer.pendingCandidates.splice(0)) {
|
|
await pc.addIceCandidate(c).catch((e) => console.warn('buffered candidate failed', e));
|
|
}
|
|
if (desc.type === 'offer') {
|
|
await pc.setLocalDescription();
|
|
send({ type: 'answer', to: peer.id, sdp: pc.localDescription });
|
|
}
|
|
}
|
|
|
|
async function onCandidate(peer, candidate) {
|
|
if (peer.ignoreOffer) return;
|
|
if (!peer.pc.remoteDescription) {
|
|
peer.pendingCandidates.push(candidate);
|
|
return;
|
|
}
|
|
await peer.pc.addIceCandidate(candidate);
|
|
}
|
|
|
|
function destroyPeer(peer) {
|
|
if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; }
|
|
detachSpatial(peer);
|
|
peer.micStream = null;
|
|
peer.screenStream = null;
|
|
peer.els = {};
|
|
renderScreens();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// remote media: mic -> spatial chain, screen -> big video + direct audio
|
|
// ---------------------------------------------------------------------------
|
|
function onRemoteTrack(peer, ev) {
|
|
const stream = ev.streams[0];
|
|
if (!stream) return;
|
|
if (stream.getVideoTracks().length > 0) {
|
|
peer.screenStream = stream;
|
|
stream.getVideoTracks().forEach((t) => {
|
|
t.addEventListener('ended', () => {
|
|
if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); }
|
|
});
|
|
});
|
|
renderScreens();
|
|
console.log('remote screen share from', peer.name);
|
|
} else {
|
|
peer.micStream = stream;
|
|
attachSpatial(peer, stream);
|
|
console.log('remote mic from', peer.name);
|
|
}
|
|
renderTiles();
|
|
}
|
|
|
|
function attachSpatial(peer, stream) {
|
|
const ctx = S.audioCtx;
|
|
if (!ctx || peer.panner) return;
|
|
const src = ctx.createMediaStreamSource(stream);
|
|
const panner = ctx.createPanner();
|
|
panner.panningModel = 'HRTF';
|
|
panner.distanceModel = 'inverse';
|
|
panner.refDistance = 4;
|
|
panner.rolloffFactor = 1.4;
|
|
panner.maxDistance = 200;
|
|
const gain = ctx.createGain();
|
|
gain.gain.value = S.deafened ? 0 : peer.vol;
|
|
const analyser = ctx.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
src.connect(panner).connect(gain).connect(analyser).connect(ctx.destination);
|
|
Object.assign(peer, { panner, gain, analyser });
|
|
updatePanner(peer);
|
|
}
|
|
|
|
function detachSpatial(peer) {
|
|
if (peer.gain) { try { peer.gain.disconnect(); } catch {} }
|
|
if (peer.panner) { try { peer.panner.disconnect(); } catch {} }
|
|
peer.panner = peer.gain = peer.analyser = null;
|
|
}
|
|
|
|
function updatePanner(peer) {
|
|
if (!peer.panner) return;
|
|
const x = (peer.x - S.me.x) * SPATIAL_SCALE;
|
|
const z = (peer.y - S.me.y) * SPATIAL_SCALE; // screen-down = behind listener
|
|
const p = peer.panner;
|
|
if (p.positionX) {
|
|
p.positionX.value = x; p.positionY.value = 0; p.positionZ.value = z;
|
|
} else { // old Safari
|
|
p.setPosition(x, 0, z);
|
|
}
|
|
}
|
|
|
|
function orientListener() {
|
|
const l = S.audioCtx && S.audioCtx.listener;
|
|
if (!l) return;
|
|
if (l.forwardX) {
|
|
l.forwardX.value = 0; l.forwardY.value = 0; l.forwardZ.value = -1;
|
|
l.upX.value = 0; l.upY.value = 1; l.upZ.value = 0;
|
|
} else {
|
|
l.setOrientation(0, 0, -1, 0, 1, 0);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// screen share (local)
|
|
// ---------------------------------------------------------------------------
|
|
async function startScreenShare() {
|
|
if (S.sharing) {
|
|
stopScreenShare();
|
|
return;
|
|
}
|
|
if (!S.room) {
|
|
toast('Join a voice channel first to share your screen', 'warn');
|
|
return;
|
|
}
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
|
toast('Screen sharing is not supported in this browser or context', 'error');
|
|
return;
|
|
}
|
|
let stream;
|
|
try {
|
|
// Try requesting display media with audio first
|
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { frameRate: 30 }, audio: true,
|
|
});
|
|
} catch (err) {
|
|
// If user cancelled / denied, don't toast as error unless it's not NotAllowedError/AbortError
|
|
if (err.name === 'NotAllowedError' || err.name === 'AbortError') {
|
|
return;
|
|
}
|
|
// Try fallback without audio constraint if audio failed
|
|
try {
|
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { frameRate: 30 },
|
|
});
|
|
} catch (fallbackErr) {
|
|
if (fallbackErr.name === 'NotAllowedError' || fallbackErr.name === 'AbortError') {
|
|
return;
|
|
}
|
|
console.error('Failed to get display media:', fallbackErr);
|
|
toast(`Could not share screen: ${fallbackErr.message || fallbackErr}`, 'error');
|
|
return;
|
|
}
|
|
}
|
|
S.screenStream = stream;
|
|
S.sharing = true;
|
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: true } });
|
|
|
|
for (const peer of S.peers.values()) addScreenToPeer(peer);
|
|
const videoTrack = stream.getVideoTracks()[0];
|
|
if (videoTrack) {
|
|
videoTrack.addEventListener('ended', () => stopScreenShare());
|
|
}
|
|
renderScreens();
|
|
renderTiles();
|
|
$('#btn-share').classList.add('active');
|
|
toast('You are sharing your screen', 'ok');
|
|
}
|
|
|
|
function addScreenToPeer(peer) {
|
|
if (!peer.pc || !S.screenStream) return;
|
|
const senders = [];
|
|
for (const track of S.screenStream.getTracks()) {
|
|
senders.push(peer.pc.addTrack(track, S.screenStream));
|
|
}
|
|
S.screenSenders.set(peer.id, senders); // renegotiation fires automatically
|
|
}
|
|
|
|
function stopScreenShare(silent) {
|
|
if (!S.sharing) return;
|
|
S.sharing = false;
|
|
for (const [peerId, senders] of S.screenSenders) {
|
|
const peer = S.peers.get(peerId);
|
|
if (peer && peer.pc) for (const s of senders) { try { peer.pc.removeTrack(s); } catch {} }
|
|
}
|
|
S.screenSenders.clear();
|
|
if (S.screenStream) { S.screenStream.getTracks().forEach((t) => t.stop()); S.screenStream = null; }
|
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: false } });
|
|
renderScreens();
|
|
renderTiles();
|
|
$('#btn-share').classList.remove('active');
|
|
if (!silent) toast('Screen share stopped');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// position sync
|
|
// ---------------------------------------------------------------------------
|
|
let lastPosSend = 0;
|
|
function sendMyPos(force) {
|
|
const now = performance.now();
|
|
if (!force && now - lastPosSend < 60) return;
|
|
lastPosSend = now;
|
|
send({ type: 'pos', x: S.me.x, y: S.me.y });
|
|
for (const peer of S.peers.values()) updatePanner(peer);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// speaking detection (local meter + per-peer analyser RMS)
|
|
// ---------------------------------------------------------------------------
|
|
const meterBuf = new Uint8Array(256);
|
|
function rms(analyser) {
|
|
analyser.getByteTimeDomainData(meterBuf);
|
|
let sum = 0;
|
|
for (let i = 0; i < meterBuf.length; i++) {
|
|
const v = (meterBuf[i] - 128) / 128;
|
|
sum += v * v;
|
|
}
|
|
return Math.sqrt(sum / meterBuf.length);
|
|
}
|
|
|
|
let localAnalyser = null;
|
|
setInterval(() => {
|
|
if (!S.audioCtx) return;
|
|
if (S.micStream && !localAnalyser) {
|
|
const src = S.audioCtx.createMediaStreamSource(S.micStream);
|
|
localAnalyser = S.audioCtx.createAnalyser();
|
|
localAnalyser.fftSize = 256;
|
|
src.connect(localAnalyser); // tap only — not connected to destination (no echo)
|
|
}
|
|
const meSpeaking = localAnalyser && !S.muted && rms(localAnalyser) > 0.04;
|
|
els.meAvatar.classList.toggle('speaking', !!meSpeaking);
|
|
let changed = false;
|
|
for (const peer of S.peers.values()) {
|
|
const speaking = !!(peer.analyser && !S.deafened && rms(peer.analyser) > 0.03);
|
|
if (speaking !== peer.speaking) { peer.speaking = speaking; changed = true; }
|
|
}
|
|
if (changed) renderTiles();
|
|
updateScreenTileIndicators();
|
|
}, 150);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rendering
|
|
// ---------------------------------------------------------------------------
|
|
function renderAll() {
|
|
renderTiles();
|
|
renderScreens();
|
|
renderStageEmpty();
|
|
renderChannelCounts();
|
|
}
|
|
|
|
function renderStageEmpty() {
|
|
const hasScreens = activeShares().length > 0;
|
|
els.stage.classList.toggle('empty', !hasScreens);
|
|
}
|
|
|
|
function renderChannelCounts() {
|
|
const counts = {};
|
|
// Count users across all rooms known from snapshot
|
|
for (const [rId, users] of Object.entries(S.channelRosters || {})) {
|
|
counts[rId] = users ? users.length : 0;
|
|
}
|
|
// If active room has peers, ensure active count includes me + peers
|
|
if (S.room) {
|
|
counts[S.room] = S.peers.size + 1;
|
|
}
|
|
document.querySelectorAll('[data-room-count]').forEach((el) => {
|
|
const n = counts[el.dataset.roomCount] || 0;
|
|
el.textContent = n ? `${n}` : '';
|
|
});
|
|
}
|
|
|
|
function renderTiles() {
|
|
// Clear any existing user rosters in voice channels
|
|
document.querySelectorAll('.channel-users').forEach((el) => { el.innerHTML = ''; });
|
|
|
|
// For each voice channel element in the sidebar
|
|
document.querySelectorAll('.channel.voice').forEach((channelEl) => {
|
|
const roomId = channelEl.dataset.room;
|
|
if (!roomId) return;
|
|
|
|
let roster = channelEl.nextElementSibling;
|
|
if (!roster || !roster.classList.contains('channel-users')) {
|
|
roster = document.createElement('div');
|
|
roster.className = 'channel-users';
|
|
channelEl.after(roster);
|
|
}
|
|
|
|
if (S.room && S.room === roomId) {
|
|
// Current active room: render Me + live peers (with interactive volume sliders)
|
|
const meItem = document.createElement('div');
|
|
meItem.className = 'channel-user-item' + (els.meAvatar.classList.contains('speaking') ? ' speaking' : '');
|
|
const meAv = document.createElement('div');
|
|
meAv.className = 'avatar user-line-avatar';
|
|
setAvatar(meAv, S.name, S.myId || 'me');
|
|
const meName = document.createElement('span');
|
|
meName.className = 'user-line-name';
|
|
meName.textContent = `${S.name} (you)`;
|
|
|
|
const meBadges = document.createElement('span');
|
|
meBadges.className = 'user-line-badges';
|
|
if (S.muted) meBadges.innerHTML += '<span class="badge-mute" title="Muted">🎤️‍❌</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);
|
|
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
const ctx2d = els.spatial.getContext('2d');
|
|
let dragging = null;
|
|
|
|
// Camera is always centered on the listener (me), matching how the audio
|
|
// panning already works (peer positions are relative to S.me). While
|
|
// dragging, the camera freezes at its pre-drag position so the world
|
|
// doesn't slide under the pointer as S.me moves.
|
|
function spatialMetrics(camX = S.me.x, camY = S.me.y) {
|
|
const r = els.spatial.getBoundingClientRect();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
if (els.spatial.width !== r.width * dpr) {
|
|
els.spatial.width = r.width * dpr;
|
|
els.spatial.height = r.height * dpr;
|
|
}
|
|
ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
const pad = 30;
|
|
const sx = (r.width - 2 * pad) / ROOM_UNITS;
|
|
const sy = (r.height - 2 * pad) / ROOM_UNITS;
|
|
const cx = r.width / 2, cy = r.height / 2;
|
|
return { w: r.width, h: r.height, pad, sx, sy,
|
|
toPx: (x, y) => [cx + (x - camX) * sx, cy + (y - camY) * sy],
|
|
fromPx: (px, py) => [camX + (px - cx) / sx, camY + (py - cy) / sy] };
|
|
}
|
|
|
|
function drawSpatial() {
|
|
const camX = dragging ? dragging.camX : S.me.x;
|
|
const camY = dragging ? dragging.camY : S.me.y;
|
|
const m = spatialMetrics(camX, camY);
|
|
ctx2d.clearRect(0, 0, m.w, m.h);
|
|
|
|
// grid (world bounds only — clamped so it doesn't imply an infinite plane)
|
|
ctx2d.strokeStyle = 'rgba(255,255,255,.05)';
|
|
ctx2d.lineWidth = 1;
|
|
for (let i = 0; i <= ROOM_UNITS; i += 10) {
|
|
const [gx] = m.toPx(i, 0);
|
|
const [, gy] = m.toPx(0, i);
|
|
ctx2d.beginPath(); ctx2d.moveTo(gx, m.toPx(0, 0)[1]); ctx2d.lineTo(gx, m.toPx(0, ROOM_UNITS)[1]); ctx2d.stroke();
|
|
ctx2d.beginPath(); ctx2d.moveTo(m.toPx(0, 0)[0], gy); ctx2d.lineTo(m.toPx(ROOM_UNITS, 0)[0], gy); ctx2d.stroke();
|
|
}
|
|
|
|
// audible range around me (~gain 0.05 at 56 units with ref=4, rolloff=1.4)
|
|
const [mx, my] = m.toPx(S.me.x, S.me.y);
|
|
const rangeR = 56 * m.sx;
|
|
ctx2d.strokeStyle = 'rgba(88,101,242,.35)';
|
|
ctx2d.setLineDash([5, 5]);
|
|
ctx2d.beginPath(); ctx2d.arc(mx, my, Math.max(0, Math.min(rangeR, m.h / 2 - m.pad)), 0, Math.PI * 2); ctx2d.stroke();
|
|
ctx2d.setLineDash([]);
|
|
|
|
// peers
|
|
for (const peer of S.peers.values()) drawAvatarDot(peer.name, peer.id, peer.x, peer.y, peer.speaking, peer.state.muted, false, m);
|
|
// me (draggable listener, always drawn at the camera's center)
|
|
drawAvatarDot(S.name || 'me', S.myId || 'me', S.me.x, S.me.y, els.meAvatar.classList.contains('speaking'), S.muted, true, m);
|
|
|
|
if (!dragging) requestAnimationFrame(drawSpatial);
|
|
}
|
|
requestAnimationFrame(drawSpatial);
|
|
|
|
function drawAvatarDot(name, id, x, y, speaking, muted, isMe, m) {
|
|
const [px, py] = m.toPx(x, y);
|
|
const r = isMe ? 16 : 13;
|
|
ctx2d.beginPath();
|
|
ctx2d.arc(px, py, r, 0, Math.PI * 2);
|
|
ctx2d.fillStyle = colorFor(id);
|
|
ctx2d.fill();
|
|
if (speaking) {
|
|
ctx2d.strokeStyle = '#23a55a';
|
|
ctx2d.lineWidth = 3;
|
|
ctx2d.stroke();
|
|
}
|
|
if (muted) {
|
|
ctx2d.strokeStyle = '#f23f43';
|
|
ctx2d.lineWidth = 3;
|
|
ctx2d.beginPath();
|
|
ctx2d.moveTo(px - r, py + r);
|
|
ctx2d.lineTo(px + r, py - r);
|
|
ctx2d.stroke();
|
|
}
|
|
ctx2d.fillStyle = '#fff';
|
|
ctx2d.font = `bold ${Math.round(r)}px system-ui`;
|
|
ctx2d.textAlign = 'center';
|
|
ctx2d.textBaseline = 'middle';
|
|
ctx2d.fillText(name.slice(0, 1).toUpperCase(), px, py + 1);
|
|
ctx2d.font = '11px system-ui';
|
|
ctx2d.fillStyle = isMe ? '#b5bac1' : '#80848e';
|
|
ctx2d.fillText(name + (isMe ? ' (you)' : ''), px, py + r + 12);
|
|
}
|
|
|
|
els.spatial.addEventListener('pointerdown', (ev) => {
|
|
const m = spatialMetrics();
|
|
const rect = els.spatial.getBoundingClientRect();
|
|
const px = ev.clientX - rect.left, py = ev.clientY - rect.top;
|
|
const [ux, uy] = m.fromPx(px, py);
|
|
const d = Math.hypot(ux - S.me.x, uy - S.me.y);
|
|
if (d < 10) {
|
|
dragging = { dx: S.me.x - ux, dy: S.me.y - uy, camX: S.me.x, camY: S.me.y };
|
|
els.spatial.classList.add('dragging');
|
|
els.spatial.setPointerCapture(ev.pointerId);
|
|
}
|
|
});
|
|
els.spatial.addEventListener('pointermove', (ev) => {
|
|
if (!dragging) return;
|
|
const m = spatialMetrics(dragging.camX, dragging.camY);
|
|
const rect = els.spatial.getBoundingClientRect();
|
|
const [ux, uy] = m.fromPx(ev.clientX - rect.left, ev.clientY - rect.top);
|
|
S.me.x = Math.min(ROOM_UNITS, Math.max(0, ux + dragging.dx));
|
|
S.me.y = Math.min(ROOM_UNITS, Math.max(0, uy + dragging.dy));
|
|
drawSpatial();
|
|
sendMyPos();
|
|
});
|
|
els.spatial.addEventListener('pointerup', () => {
|
|
if (!dragging) return;
|
|
dragging = null;
|
|
els.spatial.classList.remove('dragging');
|
|
sendMyPos(true);
|
|
requestAnimationFrame(drawSpatial); // rAF loop stopped while dragging
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Matrix text chat (experimental) — native OAuth2/OIDC auth against the
|
|
// homeserver's own authorization server (MSC2965/MSC3861), so there's no
|
|
// separate Matrix password: login redirects to matrix.k6n.net itself.
|
|
// No E2E crypto — rooms are plaintext, matching this app's POC posture.
|
|
// ---------------------------------------------------------------------------
|
|
const MX_BASE_URL = 'https://matrix.k6n.net';
|
|
const MX_SERVER_NAME = 'matrix.k6n.net';
|
|
const LS_MX_CLIENT_ID = 'voicepoc.mxClientId';
|
|
const LS_MX_SESSION = 'voicepoc.mxSession';
|
|
const SS_MX_PENDING = 'voicepoc.mxOidcPending';
|
|
|
|
const Mx = { client: null, roomId: null };
|
|
|
|
function mxRedirectUri() {
|
|
return location.origin + location.pathname;
|
|
}
|
|
|
|
async function mxRegisterClientId(authMetadata) {
|
|
const cached = localStorage.getItem(LS_MX_CLIENT_ID);
|
|
if (cached) return cached;
|
|
const clientId = await matrixcs.OAuth2.registerClient(authMetadata, {
|
|
client_name: 'gibbz voice',
|
|
client_uri: mxRedirectUri(),
|
|
redirect_uris: [mxRedirectUri()],
|
|
application_type: 'web',
|
|
});
|
|
localStorage.setItem(LS_MX_CLIENT_ID, clientId);
|
|
return clientId;
|
|
}
|
|
|
|
async function mxStartLogin() {
|
|
try {
|
|
const bare = matrixcs.createClient({ baseUrl: MX_BASE_URL });
|
|
const authMetadata = await bare.getAuthMetadata();
|
|
const clientId = await mxRegisterClientId(authMetadata);
|
|
const oauth = new matrixcs.OAuth2(authMetadata, { clientId, redirectUri: mxRedirectUri() });
|
|
const state = crypto.randomUUID();
|
|
sessionStorage.setItem(SS_MX_PENDING, JSON.stringify({
|
|
state,
|
|
authMetadata,
|
|
clientId,
|
|
deviceId: oauth.context.deviceId,
|
|
codeVerifier: oauth.context.codeVerifier,
|
|
redirectUri: oauth.context.redirectUri,
|
|
}));
|
|
const url = await oauth.generateAuthorizationCodeGrantUrl(state, 'query');
|
|
window.location.href = url;
|
|
} catch (err) {
|
|
console.error('matrix login failed to start', err);
|
|
toast(`Matrix login failed: ${err.message || err}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function mxCompleteLogin(code, state) {
|
|
const raw = sessionStorage.getItem(SS_MX_PENDING);
|
|
sessionStorage.removeItem(SS_MX_PENDING);
|
|
if (!raw) { toast('Matrix login callback received but no pending login found', 'error'); return; }
|
|
const pending = JSON.parse(raw);
|
|
if (pending.state !== state) { toast('Matrix login state mismatch — try again', 'error'); return; }
|
|
try {
|
|
const oauth = new matrixcs.OAuth2(pending.authMetadata, {
|
|
clientId: pending.clientId,
|
|
deviceId: pending.deviceId,
|
|
codeVerifier: pending.codeVerifier,
|
|
redirectUri: pending.redirectUri,
|
|
});
|
|
const tokens = await oauth.completeAuthorizationCodeGrant(code);
|
|
await mxAdoptTokens(tokens);
|
|
toast('Matrix connected', 'ok');
|
|
mxShowPanel();
|
|
} catch (err) {
|
|
console.error('matrix login completion failed', err);
|
|
toast(`Matrix login failed: ${err.message || err}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function mxAdoptTokens(tokens) {
|
|
const probe = matrixcs.createClient({ baseUrl: MX_BASE_URL, accessToken: tokens.access_token });
|
|
const who = await probe.whoami();
|
|
const session = {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || null,
|
|
userId: who.user_id,
|
|
deviceId: who.device_id,
|
|
};
|
|
localStorage.setItem(LS_MX_SESSION, JSON.stringify(session));
|
|
await mxBoot(session);
|
|
}
|
|
|
|
async function mxBoot(session) {
|
|
Mx.client = matrixcs.createClient({
|
|
baseUrl: MX_BASE_URL,
|
|
accessToken: session.accessToken,
|
|
refreshToken: session.refreshToken || undefined,
|
|
userId: session.userId,
|
|
deviceId: session.deviceId,
|
|
});
|
|
Mx.client.on(matrixcs.ClientEvent.Sync, (state) => {
|
|
if (state === 'PREPARED') mxOnReady();
|
|
});
|
|
Mx.client.on(matrixcs.RoomEvent.Timeline, (event, room) => {
|
|
if (room && room.roomId === Mx.roomId && event.getType() === 'm.room.message') mxRenderTimeline();
|
|
});
|
|
await Mx.client.startClient({ initialSyncLimit: 30 });
|
|
}
|
|
|
|
async function mxOnReady() {
|
|
try {
|
|
Mx.roomId = await mxJoinOrCreateRoom('gibbz-general', 'gibbz general');
|
|
mxRenderTimeline();
|
|
} catch (err) {
|
|
console.error('matrix room join/create failed', err);
|
|
toast(`Could not open #general text channel: ${err.message || err}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function mxJoinOrCreateRoom(aliasLocalPart, name) {
|
|
const alias = `#${aliasLocalPart}:${MX_SERVER_NAME}`;
|
|
try {
|
|
const res = await Mx.client.joinRoom(alias);
|
|
return res.roomId;
|
|
} catch (err) {
|
|
if (err.errcode !== 'M_NOT_FOUND') throw err;
|
|
const created = await Mx.client.createRoom({
|
|
room_alias_name: aliasLocalPart,
|
|
name,
|
|
visibility: 'public',
|
|
preset: 'public_chat',
|
|
});
|
|
return created.room_id;
|
|
}
|
|
}
|
|
|
|
function mxRenderTimeline() {
|
|
if (!Mx.client || !Mx.roomId || !els.mxMessages) return;
|
|
const room = Mx.client.getRoom(Mx.roomId);
|
|
if (!room) return;
|
|
const events = room.getLiveTimeline().getEvents().filter((e) => e.getType() === 'm.room.message');
|
|
els.mxMessages.innerHTML = '';
|
|
for (const ev of events) {
|
|
const row = document.createElement('div');
|
|
row.className = 'mx-msg';
|
|
const sender = document.createElement('span');
|
|
sender.className = 'mx-sender';
|
|
sender.textContent = (ev.sender && ev.sender.name) || ev.getSender();
|
|
const body = document.createElement('span');
|
|
body.className = 'mx-body';
|
|
body.textContent = ev.getContent().body || '';
|
|
row.append(sender, body);
|
|
els.mxMessages.appendChild(row);
|
|
}
|
|
els.mxMessages.scrollTop = els.mxMessages.scrollHeight;
|
|
}
|
|
|
|
function mxShowPanel() {
|
|
if (els.mxPanel) els.mxPanel.classList.remove('hidden');
|
|
}
|
|
|
|
function mxSend(text) {
|
|
if (!Mx.client || !Mx.roomId || !text.trim()) return;
|
|
Mx.client.sendTextMessage(Mx.roomId, text.trim()).catch((err) => {
|
|
console.error('matrix send failed', err);
|
|
toast(`Message failed to send: ${err.message || err}`, 'error');
|
|
});
|
|
}
|
|
|
|
async function mxInit() {
|
|
const params = new URLSearchParams(location.search);
|
|
if (params.has('code') && params.has('state')) {
|
|
const code = params.get('code'), state = params.get('state');
|
|
history.replaceState(null, '', location.pathname);
|
|
await mxCompleteLogin(code, state);
|
|
return;
|
|
}
|
|
const raw = localStorage.getItem(LS_MX_SESSION);
|
|
if (raw) {
|
|
try {
|
|
await mxBoot(JSON.parse(raw));
|
|
} catch (err) {
|
|
console.warn('resuming matrix session failed', err);
|
|
localStorage.removeItem(LS_MX_SESSION);
|
|
}
|
|
}
|
|
}
|
|
mxInit();
|
|
|
|
document.querySelectorAll('.channel.text').forEach((el) => {
|
|
el.onclick = () => {
|
|
if (Mx.roomId) { mxShowPanel(); return; }
|
|
if (Mx.client) { toast('Connecting to Matrix…'); return; }
|
|
mxStartLogin();
|
|
};
|
|
});
|
|
$('#mx-close')?.addEventListener('click', () => els.mxPanel.classList.add('hidden'));
|
|
$('#mx-send')?.addEventListener('click', () => {
|
|
mxSend(els.mxInput.value);
|
|
els.mxInput.value = '';
|
|
});
|
|
els.mxInput?.addEventListener('keydown', (ev) => {
|
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
ev.preventDefault();
|
|
$('#mx-send').click();
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// controls & wiring
|
|
// ---------------------------------------------------------------------------
|
|
function updateMicButton() {
|
|
const off = S.muted || !S.micStream;
|
|
els.mic.classList.toggle('off', off);
|
|
els.mic.textContent = off ? '\u{1F507}' : '\u{1F3A4}'; // 🔇 muted/no-mic vs 🎤 live
|
|
}
|
|
function updateDeafenButton() {
|
|
els.deafen.classList.toggle('off', S.deafened);
|
|
}
|
|
function sendState() {
|
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: S.sharing } });
|
|
}
|
|
|
|
function toggleMic() {
|
|
if (!S.micStream) return;
|
|
S.muted = !S.muted;
|
|
S.micStream.getAudioTracks().forEach((t) => (t.enabled = !S.muted));
|
|
updateMicButton();
|
|
sendState();
|
|
renderTiles();
|
|
}
|
|
|
|
function toggleDeafen() {
|
|
if (!S.audioCtx) return;
|
|
S.deafened = !S.deafened;
|
|
for (const peer of S.peers.values()) {
|
|
if (peer.gain) peer.gain.gain.value = S.deafened ? 0 : peer.vol;
|
|
}
|
|
updateDeafenButton();
|
|
sendState();
|
|
renderTiles();
|
|
}
|
|
|
|
els.mic.onclick = toggleMic;
|
|
els.deafen.onclick = toggleDeafen;
|
|
$('#btn-share').onclick = () => (S.sharing ? stopScreenShare() : startScreenShare());
|
|
$('#btn-settings').onclick = openSettings;
|
|
|
|
els.nick.value = S.name;
|
|
els.nick.addEventListener('change', () => {
|
|
S.name = els.nick.value.trim() || `guest-${Math.random().toString(36).slice(2, 6)}`;
|
|
els.nick.value = S.name;
|
|
localStorage.setItem(LS_NAME, S.name);
|
|
setAvatar(els.meAvatar, S.name, S.myId || 'me');
|
|
});
|
|
if (!S.name) {
|
|
S.name = `guest-${Math.random().toString(36).slice(2, 6)}`;
|
|
els.nick.value = S.name;
|
|
localStorage.setItem(LS_NAME, S.name);
|
|
}
|
|
setAvatar(els.meAvatar, S.name, 'me');
|
|
|
|
document.querySelectorAll('.channel.voice').forEach((el) => {
|
|
el.onclick = () => {
|
|
if (S.room === el.dataset.room) return;
|
|
cancelReconnect();
|
|
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
|
el.classList.add('active');
|
|
joinVoice(el.dataset.room).catch((e) => {
|
|
console.error(e);
|
|
toast(`Join failed: ${e.message}`, 'error');
|
|
leaveVoice('join failed');
|
|
});
|
|
};
|
|
});
|
|
|
|
// Initial roster fetch and lightweight periodic sync (especially for unjoined clients)
|
|
updateMicButton();
|
|
updateDeafenButton();
|
|
fetchRooms();
|
|
setInterval(() => {
|
|
// If not connected to websocket, poll for changes across rooms
|
|
if (!S.ws || S.ws.readyState !== 1) {
|
|
fetchRooms();
|
|
}
|
|
}, 5000);
|
|
|
|
// spawn me somewhere spread out, not dead-center on top of others
|
|
(() => {
|
|
let h = 0;
|
|
for (const c of S.myId || S.name) h = (h * 33 + c.charCodeAt(0)) >>> 0;
|
|
const a = (h % 360) * (Math.PI / 180);
|
|
S.me.x = 50 + 20 * Math.cos(a);
|
|
S.me.y = 50 + 20 * Math.sin(a);
|
|
})();
|
|
|
|
window.addEventListener('keydown', (ev) => {
|
|
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return;
|
|
if (ev.key === 'm') toggleMic();
|
|
if (ev.key === 'd') toggleDeafen();
|
|
if (ev.key === 's') $('#btn-share').click();
|
|
});
|
|
window.addEventListener('beforeunload', () => { try { cancelReconnect(); leaveVoice(); } catch {} });
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// settings modal (custom STUN/TURN)
|
|
// ---------------------------------------------------------------------------
|
|
function openSettings() {
|
|
const override = localStorage.getItem(LS_ICE);
|
|
$('#override-enabled').checked = !!override;
|
|
const current = override ? JSON.parse(override) : effectiveIce();
|
|
$('#ice-json').value = JSON.stringify(current, null, 2);
|
|
fetchIce().then(
|
|
(server) => { $('#ice-server-default').textContent = JSON.stringify(server, null, 2); },
|
|
() => { $('#ice-server-default').textContent = '(unavailable)'; },
|
|
);
|
|
$('#modal-backdrop').classList.remove('hidden');
|
|
}
|
|
$('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden');
|
|
$('#modal-backdrop').onclick = (ev) => {
|
|
if (ev.target === $('#modal-backdrop')) $('#modal-backdrop').classList.add('hidden');
|
|
};
|
|
$('#ice-reset').onclick = () => {
|
|
localStorage.removeItem(LS_ICE);
|
|
$('#override-enabled').checked = false;
|
|
fetchIce().then((server) => { $('#ice-json').value = JSON.stringify(server, null, 2); });
|
|
};
|
|
$('#ice-save').onclick = () => {
|
|
const enabled = $('#override-enabled').checked;
|
|
let parsed = null;
|
|
if (enabled) {
|
|
try {
|
|
parsed = JSON.parse($('#ice-json').value);
|
|
if (!Array.isArray(parsed.iceServers)) throw new Error('missing "iceServers" array');
|
|
} catch (err) {
|
|
toast(`Invalid ICE JSON: ${err.message}`, 'error');
|
|
return;
|
|
}
|
|
localStorage.setItem(LS_ICE, JSON.stringify(parsed));
|
|
} else {
|
|
localStorage.removeItem(LS_ICE);
|
|
}
|
|
$('#modal-backdrop').classList.add('hidden');
|
|
toast(enabled ? 'Custom ICE saved — rejoin to apply' : 'Reverted to server ICE — rejoin to apply', 'ok');
|
|
};
|