voice-poc: WebRTC voice + screen share + spatial audio with configurable STUN/TURN
This commit is contained in:
1 parent
89053153cc
commit
404dae3a28
9 files changed
+1386
No files matched your search
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# gibbz voice — POC
|
||||||
|
|
||||||
|
Discord-clone proof of concept: peer-to-peer WebRTC voice chat + screen sharing
|
||||||
|
with position-based spatial audio and pluggable STUN/TURN.
|
||||||
|
|
||||||
|
Run (Node 18+, only dep is `ws`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
PORT=8081 npm start
|
||||||
|
# open http://localhost:8081, click a voice channel, allow mic
|
||||||
|
```
|
||||||
|
|
||||||
|
## What works
|
||||||
|
|
||||||
|
- Mesh WebRTC voice (perfect negotiation; lowest peer id initiates)
|
||||||
|
- Screen share with audio (mic is spatialized, screen audio plays direct)
|
||||||
|
- Spatial audio: drag your avatar on the plane; remote mics run through a
|
||||||
|
`PannerNode` (HRTF, inverse distance). Positions sync over signaling.
|
||||||
|
- Custom STUN/TURN: gear icon → JSON override in `localStorage`, applied on
|
||||||
|
next join. Server default comes from env (below).
|
||||||
|
- Mute (M), deafen (D), per-peer volume, speaking indicators.
|
||||||
|
|
||||||
|
## ICE configuration (server side)
|
||||||
|
|
||||||
|
| Env var | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `STUN_URLS` | comma-separated `stun:` urls (Google defaults) |
|
||||||
|
| `TURN_URLS` | comma-separated `turn:`/`turns:` urls |
|
||||||
|
| `TURN_USERNAME` / `TURN_CREDENTIAL` | static TURN creds |
|
||||||
|
| `ICE_SERVERS_JSON` | full `{"iceServers":[...]}` override, wins over the above |
|
||||||
|
| `PORT` / `HOST` | listen address (default 8081 / 0.0.0.0) |
|
||||||
|
|
||||||
|
Example with coturn:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TURN_URLS='turn:turn.example.com:3478?transport=udp,turns:turn.example.com:5349' \
|
||||||
|
TURN_USERNAME=mats TURN_CREDENTIAL=secret npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## POC shortcuts (productionize later)
|
||||||
|
|
||||||
|
- No auth, no reconnection, rooms are in-memory, signaling is unencrypted ws
|
||||||
|
behind the preview ingress (use wss/TLS in front for real deploys).
|
||||||
|
- No media server: N² mesh — fine for small rooms, needs SFU (mediasoup/livekit)
|
||||||
|
beyond ~6–8 participants.
|
||||||
|
- Text channels are decorative. No persistence, no rate limiting.
|
||||||
|
- Screen-share negotiation storms on join/leave are expected at small scale.
|
||||||
Generated
+36
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "gibbz-voice-poc",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "gibbz-voice-poc",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "gibbz-voice-poc",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Discord-clone POC: WebRTC voice chat + screen sharing + spatial audio",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
/* gibbz voice POC — WebRTC mesh voice + screen share + spatial audio.
|
||||||
|
POC shortcuts: no auth, no reconnection, no text chat, single audio context. */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: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,
|
||||||
|
};
|
||||||
|
window.__dbg = S; // POC debug handle
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
const els = {
|
||||||
|
stage: $('#stage'), screens: $('#screens'), tiles: $('#tiles'),
|
||||||
|
spatial: $('#spatial'), status: $('#voice-status'), nick: $('#nick'),
|
||||||
|
meAvatar: $('#me-avatar'), mic: $('#btn-mic'), deafen: $('#btn-deafen'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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}…`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
S.iceServers = await fetchIce();
|
||||||
|
} catch {
|
||||||
|
toast('Could not load ICE config from server — using fallback STUN', 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
// mic (join anyway without one — listen-only)
|
||||||
|
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;
|
||||||
|
|
||||||
|
// audio context must exist after a user gesture — the channel click is one
|
||||||
|
if (!S.audioCtx) S.audioCtx = new AudioContext();
|
||||||
|
await S.audioCtx.resume().catch(() => {});
|
||||||
|
orientListener();
|
||||||
|
|
||||||
|
await openSignaling(room);
|
||||||
|
S.connecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIce() {
|
||||||
|
const res = await fetch('/api/ice');
|
||||||
|
if (!res.ok) throw new Error(`ice ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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;
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text) { els.status.textContent = text; }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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'); 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.ws === ws && S.room) {
|
||||||
|
toast('Signaling connection lost', 'error');
|
||||||
|
leaveVoice('disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onopen = () => resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(msg) {
|
||||||
|
if (S.ws && S.ws.readyState === 1) S.ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSignal(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'hello': {
|
||||||
|
S.myId = msg.id;
|
||||||
|
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}`);
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || !S.room) return;
|
||||||
|
let stream;
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { frameRate: 30 }, audio: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return; // user cancelled
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
stream.getVideoTracks()[0].addEventListener('ended', () => stopScreenShare());
|
||||||
|
renderScreens();
|
||||||
|
renderTiles();
|
||||||
|
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();
|
||||||
|
if (!silent) toast('Screen share stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// position sync
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let lastPosSend = 0;
|
||||||
|
function sendMyPos(force) {
|
||||||
|
const now = performance.now();
|
||||||
|
if (!force && now - lastPosSend < 60) return;
|
||||||
|
lastPosSend = now;
|
||||||
|
send({ type: 'pos', x: S.me.x, y: S.me.y });
|
||||||
|
for (const peer of S.peers.values()) updatePanner(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// speaking detection (local meter + per-peer analyser RMS)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const meterBuf = new Uint8Array(256);
|
||||||
|
function rms(analyser) {
|
||||||
|
analyser.getByteTimeDomainData(meterBuf);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < meterBuf.length; i++) {
|
||||||
|
const v = (meterBuf[i] - 128) / 128;
|
||||||
|
sum += v * v;
|
||||||
|
}
|
||||||
|
return Math.sqrt(sum / meterBuf.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
let localAnalyser = null;
|
||||||
|
setInterval(() => {
|
||||||
|
if (!S.audioCtx) return;
|
||||||
|
if (S.micStream && !localAnalyser) {
|
||||||
|
const src = S.audioCtx.createMediaStreamSource(S.micStream);
|
||||||
|
localAnalyser = S.audioCtx.createAnalyser();
|
||||||
|
localAnalyser.fftSize = 256;
|
||||||
|
src.connect(localAnalyser); // tap only — not connected to destination (no echo)
|
||||||
|
}
|
||||||
|
const meSpeaking = localAnalyser && !S.muted && rms(localAnalyser) > 0.04;
|
||||||
|
els.meAvatar.classList.toggle('speaking', !!meSpeaking);
|
||||||
|
let changed = false;
|
||||||
|
for (const peer of S.peers.values()) {
|
||||||
|
const speaking = !!(peer.analyser && !S.deafened && rms(peer.analyser) > 0.03);
|
||||||
|
if (speaking !== peer.speaking) { peer.speaking = speaking; changed = true; }
|
||||||
|
}
|
||||||
|
if (changed) renderTiles();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function renderAll() {
|
||||||
|
renderTiles();
|
||||||
|
renderScreens();
|
||||||
|
renderStageEmpty();
|
||||||
|
renderChannelCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStageEmpty() {
|
||||||
|
const anyone = S.peers.size > 0 || S.sharing || S.room;
|
||||||
|
els.stage.classList.toggle('empty', !anyone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChannelCounts() {
|
||||||
|
// POC: server does not expose cross-room rosters; count is local-room only
|
||||||
|
const counts = {};
|
||||||
|
for (const p of S.peers.values()) counts[S.room] = (counts[S.room] || 0) + 1;
|
||||||
|
document.querySelectorAll('[data-room-count]').forEach((el) => {
|
||||||
|
const n = counts[el.dataset.roomCount] || 0;
|
||||||
|
el.textContent = n ? `${n + (el.dataset.roomCount === S.room ? 1 : 0)}` : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTiles() {
|
||||||
|
els.tiles.innerHTML = '';
|
||||||
|
for (const peer of S.peers.values()) {
|
||||||
|
const tile = document.createElement('div');
|
||||||
|
tile.className = 'tile' + (peer.speaking ? ' speaking' : '');
|
||||||
|
const av = document.createElement('div');
|
||||||
|
av.className = 'avatar';
|
||||||
|
setAvatar(av, peer.name, peer.id);
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = peer.name;
|
||||||
|
const sub = document.createElement('div');
|
||||||
|
sub.className = 'sub';
|
||||||
|
const bits = [];
|
||||||
|
if (!peer.pc) bits.push('<span class="connecting">signaling…</span>');
|
||||||
|
else if (peer.pc.connectionState !== 'connected') bits.push(`<span class="connecting">${peer.pc.connectionState}…</span>`);
|
||||||
|
else if (!peer.micStream && !peer.state.muted) bits.push('<span class="connecting">no audio yet</span>');
|
||||||
|
if (peer.state.muted) bits.push('<span class="mute-tag">muted</span>');
|
||||||
|
if (peer.state.sharing) bits.push('<span class="share-tag">sharing</span>');
|
||||||
|
sub.innerHTML = bits.join(' ') || 'connected';
|
||||||
|
const vol = document.createElement('input');
|
||||||
|
vol.type = 'range'; vol.min = 0; vol.max = 2; vol.step = 0.05; vol.value = peer.vol;
|
||||||
|
vol.title = 'Volume';
|
||||||
|
vol.oninput = () => {
|
||||||
|
peer.vol = Number(vol.value);
|
||||||
|
if (peer.gain && !S.deafened) peer.gain.gain.value = peer.vol;
|
||||||
|
};
|
||||||
|
tile.append(av, name, sub, vol);
|
||||||
|
els.tiles.appendChild(tile);
|
||||||
|
}
|
||||||
|
renderStageEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreens() {
|
||||||
|
els.screens.innerHTML = '';
|
||||||
|
const shares = [];
|
||||||
|
if (S.sharing && S.screenStream) shares.push({ name: `${S.name} (you)`, stream: S.screenStream });
|
||||||
|
for (const peer of S.peers.values()) {
|
||||||
|
if (peer.screenStream) shares.push({ name: peer.name, stream: peer.screenStream });
|
||||||
|
}
|
||||||
|
for (const share of shares) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'screen-card';
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.autoplay = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.muted = false;
|
||||||
|
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));
|
||||||
|
card.append(video, label);
|
||||||
|
els.screens.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// spatial canvas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ctx2d = els.spatial.getContext('2d');
|
||||||
|
let dragging = null;
|
||||||
|
|
||||||
|
function spatialMetrics() {
|
||||||
|
const r = els.spatial.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
if (els.spatial.width !== r.width * dpr) {
|
||||||
|
els.spatial.width = r.width * dpr;
|
||||||
|
els.spatial.height = r.height * dpr;
|
||||||
|
}
|
||||||
|
ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
const pad = 30;
|
||||||
|
return { w: r.width, h: r.height, pad,
|
||||||
|
toPx: (x, y) => [pad + (x / ROOM_UNITS) * (r.width - 2 * pad),
|
||||||
|
pad + (y / ROOM_UNITS) * (r.height - 2 * pad)],
|
||||||
|
fromPx: (px, py) => [((px - pad) / (r.width - 2 * pad)) * ROOM_UNITS,
|
||||||
|
((py - pad) / (r.height - 2 * pad)) * ROOM_UNITS] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSpatial() {
|
||||||
|
const m = spatialMetrics();
|
||||||
|
ctx2d.clearRect(0, 0, m.w, m.h);
|
||||||
|
|
||||||
|
// grid
|
||||||
|
ctx2d.strokeStyle = 'rgba(255,255,255,.05)';
|
||||||
|
ctx2d.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= ROOM_UNITS; i += 10) {
|
||||||
|
const [gx, gy] = m.toPx(i, i);
|
||||||
|
ctx2d.beginPath(); ctx2d.moveTo(gx, m.toPx(0, 0)[1]); ctx2d.lineTo(gx, m.toPx(0, 100)[1]); ctx2d.stroke();
|
||||||
|
ctx2d.beginPath(); ctx2d.moveTo(m.toPx(0, 0)[0], gy); ctx2d.lineTo(m.toPx(100, 0)[0], gy); ctx2d.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// audible range around me (~gain 0.05 at 56 units with ref=4, rolloff=1.4)
|
||||||
|
const [mx, my] = m.toPx(S.me.x, S.me.y);
|
||||||
|
const rangeR = (56 / ROOM_UNITS) * (m.w - 2 * m.pad);
|
||||||
|
ctx2d.strokeStyle = 'rgba(88,101,242,.35)';
|
||||||
|
ctx2d.setLineDash([5, 5]);
|
||||||
|
ctx2d.beginPath(); ctx2d.arc(mx, my, Math.max(0, Math.min(rangeR, m.h - 2 * m.pad)), 0, Math.PI * 2); ctx2d.stroke();
|
||||||
|
ctx2d.setLineDash([]);
|
||||||
|
|
||||||
|
// peers
|
||||||
|
for (const peer of S.peers.values()) drawAvatarDot(peer.name, peer.id, peer.x, peer.y, peer.speaking, peer.state.muted, false, m);
|
||||||
|
// me (draggable listener)
|
||||||
|
drawAvatarDot(S.name || 'me', S.myId || 'me', S.me.x, S.me.y, els.meAvatar.classList.contains('speaking'), S.muted, true, m);
|
||||||
|
|
||||||
|
if (!dragging) requestAnimationFrame(drawSpatial);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(drawSpatial);
|
||||||
|
|
||||||
|
function drawAvatarDot(name, id, x, y, speaking, muted, isMe, m) {
|
||||||
|
const [px, py] = m.toPx(x, y);
|
||||||
|
const r = isMe ? 16 : 13;
|
||||||
|
ctx2d.beginPath();
|
||||||
|
ctx2d.arc(px, py, r, 0, Math.PI * 2);
|
||||||
|
ctx2d.fillStyle = colorFor(id);
|
||||||
|
ctx2d.fill();
|
||||||
|
if (speaking) {
|
||||||
|
ctx2d.strokeStyle = '#23a55a';
|
||||||
|
ctx2d.lineWidth = 3;
|
||||||
|
ctx2d.stroke();
|
||||||
|
}
|
||||||
|
if (muted) {
|
||||||
|
ctx2d.strokeStyle = '#f23f43';
|
||||||
|
ctx2d.lineWidth = 3;
|
||||||
|
ctx2d.beginPath();
|
||||||
|
ctx2d.moveTo(px - r, py + r);
|
||||||
|
ctx2d.lineTo(px + r, py - r);
|
||||||
|
ctx2d.stroke();
|
||||||
|
}
|
||||||
|
ctx2d.fillStyle = '#fff';
|
||||||
|
ctx2d.font = `bold ${Math.round(r)}px system-ui`;
|
||||||
|
ctx2d.textAlign = 'center';
|
||||||
|
ctx2d.textBaseline = 'middle';
|
||||||
|
ctx2d.fillText(name.slice(0, 1).toUpperCase(), px, py + 1);
|
||||||
|
ctx2d.font = '11px system-ui';
|
||||||
|
ctx2d.fillStyle = isMe ? '#b5bac1' : '#80848e';
|
||||||
|
ctx2d.fillText(name + (isMe ? ' (you)' : ''), px, py + r + 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
els.spatial.addEventListener('pointerdown', (ev) => {
|
||||||
|
const m = spatialMetrics();
|
||||||
|
const rect = els.spatial.getBoundingClientRect();
|
||||||
|
const px = ev.clientX - rect.left, py = ev.clientY - rect.top;
|
||||||
|
const [ux, uy] = m.fromPx(px, py);
|
||||||
|
const d = Math.hypot(ux - S.me.x, uy - S.me.y);
|
||||||
|
if (d < 10) {
|
||||||
|
dragging = { dx: S.me.x - ux, dy: S.me.y - uy };
|
||||||
|
els.spatial.classList.add('dragging');
|
||||||
|
els.spatial.setPointerCapture(ev.pointerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
els.spatial.addEventListener('pointermove', (ev) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const m = spatialMetrics();
|
||||||
|
const rect = els.spatial.getBoundingClientRect();
|
||||||
|
const [ux, uy] = m.fromPx(ev.clientX - rect.left, ev.clientY - rect.top);
|
||||||
|
S.me.x = Math.min(ROOM_UNITS, Math.max(0, ux + dragging.dx));
|
||||||
|
S.me.y = Math.min(ROOM_UNITS, Math.max(0, uy + dragging.dy));
|
||||||
|
sendMyPos();
|
||||||
|
});
|
||||||
|
els.spatial.addEventListener('pointerup', () => {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = null;
|
||||||
|
els.spatial.classList.remove('dragging');
|
||||||
|
sendMyPos(true);
|
||||||
|
requestAnimationFrame(drawSpatial); // rAF loop stopped while dragging
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// controls & wiring
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function updateMicButton() {
|
||||||
|
els.mic.classList.toggle('off', S.muted || !S.micStream);
|
||||||
|
}
|
||||||
|
function updateDeafenButton() {
|
||||||
|
els.deafen.classList.toggle('off', S.deafened);
|
||||||
|
}
|
||||||
|
function sendState() {
|
||||||
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: S.sharing } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMic() {
|
||||||
|
if (!S.micStream) return;
|
||||||
|
S.muted = !S.muted;
|
||||||
|
S.micStream.getAudioTracks().forEach((t) => (t.enabled = !S.muted));
|
||||||
|
updateMicButton();
|
||||||
|
sendState();
|
||||||
|
renderTiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDeafen() {
|
||||||
|
if (!S.audioCtx) return;
|
||||||
|
S.deafened = !S.deafened;
|
||||||
|
for (const peer of S.peers.values()) {
|
||||||
|
if (peer.gain) peer.gain.gain.value = S.deafened ? 0 : peer.vol;
|
||||||
|
}
|
||||||
|
updateDeafenButton();
|
||||||
|
sendState();
|
||||||
|
renderTiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
els.mic.onclick = toggleMic;
|
||||||
|
els.deafen.onclick = toggleDeafen;
|
||||||
|
$('#btn-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;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.channel.text').forEach((el) => {
|
||||||
|
el.onclick = () => toast('POC: text chat is not implemented');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
window.addEventListener('beforeunload', () => { try { 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');
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>gibbz voice — POC</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- server rail -->
|
||||||
|
<nav id="rail">
|
||||||
|
<div class="rail-icon home" title="gibbz voice">G</div>
|
||||||
|
<div class="rail-sep"></div>
|
||||||
|
<div class="rail-icon" title="POC server">VP</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- channel sidebar -->
|
||||||
|
<aside id="sidebar">
|
||||||
|
<header id="server-name">gibbz voice <span class="poc-tag">POC</span></header>
|
||||||
|
<div class="channel-group-label">Text channels</div>
|
||||||
|
<div class="channel text" data-noop>#
|
||||||
|
general <span class="hint">not implemented</span></div>
|
||||||
|
<div class="channel-group-label">Voice channels</div>
|
||||||
|
<div class="channel voice" data-room="general">🔊 General <span class="count" data-room-count="general"></span></div>
|
||||||
|
<div class="channel voice" data-room="gaming">🔊 Gaming <span class="count" data-room-count="gaming"></span></div>
|
||||||
|
<div class="channel voice" data-room="lounge">🔊 Lounge <span class="count" data-room-count="lounge"></span></div>
|
||||||
|
|
||||||
|
<footer id="user-panel">
|
||||||
|
<div class="me">
|
||||||
|
<div class="avatar" id="me-avatar">?</div>
|
||||||
|
<div class="me-meta">
|
||||||
|
<input id="nick" maxlength="24" spellcheck="false" title="Nickname" />
|
||||||
|
<span id="voice-status">not connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-buttons">
|
||||||
|
<button id="btn-mic" class="ctl" title="Toggle mic (M)">🎤</button>
|
||||||
|
<button id="btn-deafen" class="ctl" title="Toggle deafen (D)">👀</button>
|
||||||
|
<button id="btn-settings" class="ctl" title="Voice & ICE settings">⚙️</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- main stage -->
|
||||||
|
<main id="main">
|
||||||
|
<div id="stage">
|
||||||
|
<div id="screens"></div>
|
||||||
|
<div id="tiles"></div>
|
||||||
|
<div id="stage-empty">
|
||||||
|
<div class="big-icon">🔊</div>
|
||||||
|
<h2>Pick a voice channel to join</h2>
|
||||||
|
<p>Peer-to-peer WebRTC voice & screen sharing. Drag avatars on the spatial plane to position who you hear.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="spatial-wrap">
|
||||||
|
<div class="spatial-label">
|
||||||
|
<span>SPATIAL AUDIO</span>
|
||||||
|
<span class="hint">drag <b>your</b> avatar — others hear you shift; you hear them relative to position</span>
|
||||||
|
</div>
|
||||||
|
<canvas id="spatial"></canvas>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- settings modal -->
|
||||||
|
<div id="modal-backdrop" class="hidden">
|
||||||
|
<div id="settings-modal" role="dialog" aria-modal="true">
|
||||||
|
<h2>Voice & ICE settings</h2>
|
||||||
|
<p class="hint">Overrides the server-provided ICE configuration (stored in <code>localStorage</code>, applied on next join).</p>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" id="override-enabled" />
|
||||||
|
<span>Use custom STUN/TURN (JSON below)</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="ice-json" rows="12" spellcheck="false"></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="ice-reset" class="ghost">Reset to server defaults</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="ice-cancel" class="ghost">Cancel</button>
|
||||||
|
<button id="ice-save">Save</button>
|
||||||
|
</div>
|
||||||
|
<h3>Server defaults</h3>
|
||||||
|
<pre id="ice-server-default" class="hint"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toasts"></div>
|
||||||
|
|
||||||
|
<script src="app.js?v=4"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/* Discord-ish dark theme (POC) */
|
||||||
|
:root {
|
||||||
|
--bg-0: #1e1f22; /* rail */
|
||||||
|
--bg-1: #2b2d31; /* sidebar */
|
||||||
|
--bg-2: #313338; /* main */
|
||||||
|
--bg-3: #383a40; /* hover */
|
||||||
|
--bg-4: #404249;
|
||||||
|
--text-0: #f2f3f5;
|
||||||
|
--text-1: #b5bac1;
|
||||||
|
--text-2: #80848e;
|
||||||
|
--brand: #5865f2;
|
||||||
|
--green: #23a55a;
|
||||||
|
--red: #f23f43;
|
||||||
|
--yellow: #f0b232;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
font: 14px/1.4 "gg sans", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--text-0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#app { display: flex; height: 100vh; }
|
||||||
|
|
||||||
|
/* rail */
|
||||||
|
#rail {
|
||||||
|
width: 68px; background: var(--bg-0); padding: 12px 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rail-icon {
|
||||||
|
width: 44px; height: 44px; border-radius: 50%; background: var(--bg-3);
|
||||||
|
display: grid; place-items: center; font-weight: 700; color: var(--text-1);
|
||||||
|
cursor: pointer; transition: border-radius .15s, background .15s;
|
||||||
|
}
|
||||||
|
.rail-icon:hover, .rail-icon.home { border-radius: 16px; background: var(--brand); color: #fff; }
|
||||||
|
.rail-sep { width: 28px; height: 2px; background: var(--bg-3); border-radius: 1px; }
|
||||||
|
|
||||||
|
/* sidebar */
|
||||||
|
#sidebar {
|
||||||
|
width: 250px; background: var(--bg-1); display: flex; flex-direction: column; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#server-name {
|
||||||
|
padding: 14px 16px; font-weight: 700; font-size: 15px;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,.25); box-shadow: 0 1px 0 rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.poc-tag {
|
||||||
|
font-size: 10px; background: var(--yellow); color: #000; border-radius: 4px;
|
||||||
|
padding: 1px 5px; vertical-align: 2px; margin-left: 6px; font-weight: 800;
|
||||||
|
}
|
||||||
|
.channel-group-label {
|
||||||
|
padding: 18px 8px 4px 16px; font-size: 11px; font-weight: 700;
|
||||||
|
letter-spacing: .4px; color: var(--text-2); text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.channel {
|
||||||
|
margin: 1px 8px; padding: 7px 8px; border-radius: 6px; color: var(--text-1);
|
||||||
|
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.channel:hover { background: var(--bg-3); color: var(--text-0); }
|
||||||
|
.channel.text { cursor: not-allowed; color: var(--text-2); }
|
||||||
|
.channel.text .hint { font-size: 10px; margin-left: 4px; }
|
||||||
|
.channel.voice.active { background: rgba(88,101,242,.25); color: var(--text-0); }
|
||||||
|
.channel .count { float: right; color: var(--text-2); font-size: 12px; }
|
||||||
|
|
||||||
|
#user-panel {
|
||||||
|
margin-top: auto; background: var(--bg-0); padding: 8px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.me { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
||||||
|
.me-meta { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
#nick {
|
||||||
|
background: transparent; border: none; color: var(--text-0); font: inherit;
|
||||||
|
font-weight: 600; width: 100%; padding: 0; outline: none; border-bottom: 1px dashed transparent;
|
||||||
|
}
|
||||||
|
#nick:hover, #nick:focus { border-bottom-color: var(--text-2); }
|
||||||
|
#voice-status { font-size: 11px; color: var(--text-2); }
|
||||||
|
.user-buttons { display: flex; gap: 2px; }
|
||||||
|
.ctl {
|
||||||
|
background: transparent; border: none; color: var(--text-1); font-size: 16px;
|
||||||
|
width: 32px; height: 32px; border-radius: 6px; cursor: pointer; position: relative;
|
||||||
|
}
|
||||||
|
.ctl:hover { background: var(--bg-3); color: var(--text-0); }
|
||||||
|
.ctl.off { color: var(--red); }
|
||||||
|
.ctl.off::after {
|
||||||
|
content: ""; position: absolute; left: 7px; top: 15px; width: 18px; height: 2px;
|
||||||
|
background: var(--red); transform: rotate(-40deg); border-radius: 1px;
|
||||||
|
}
|
||||||
|
.ctl:disabled { opacity: .35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* main */
|
||||||
|
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
#stage {
|
||||||
|
flex: 1; display: flex; flex-direction: column; padding: 16px; gap: 16px;
|
||||||
|
overflow: auto; position: relative;
|
||||||
|
}
|
||||||
|
#stage-empty {
|
||||||
|
position: absolute; inset: 0; display: grid; place-content: center;
|
||||||
|
text-align: center; gap: 8px; color: var(--text-1); padding: 24px;
|
||||||
|
}
|
||||||
|
#stage-empty .big-icon { font-size: 56px; }
|
||||||
|
#stage-empty h2 { font-size: 20px; color: var(--text-0); }
|
||||||
|
#stage-empty p { max-width: 420px; color: var(--text-2); }
|
||||||
|
#stage.empty #stage-empty { display: grid; }
|
||||||
|
#stage:not(.empty) #stage-empty { display: none; }
|
||||||
|
|
||||||
|
#screens { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.screen-card {
|
||||||
|
position: relative; background: #000; border-radius: var(--radius); overflow: hidden;
|
||||||
|
flex: 1 1 480px; max-width: 100%; box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.screen-card video { width: 100%; display: block; max-height: 62vh; object-fit: contain; }
|
||||||
|
.screen-card .label {
|
||||||
|
position: absolute; left: 8px; bottom: 8px; background: rgba(0,0,0,.65);
|
||||||
|
padding: 3px 8px; border-radius: 5px; font-size: 12px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.screen-card .label .live { color: var(--red); font-weight: 800; margin-right: 5px; }
|
||||||
|
|
||||||
|
#tiles { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.tile {
|
||||||
|
background: var(--bg-1); border-radius: var(--radius); padding: 12px; width: 150px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px; position: relative;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tile.speaking { border-color: var(--green); }
|
||||||
|
.tile .avatar { width: 56px; height: 56px; font-size: 24px; }
|
||||||
|
.tile .name { font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tile .sub { font-size: 11px; color: var(--text-2); display: flex; gap: 6px; align-items: center; }
|
||||||
|
.tile .mute-tag { color: var(--red); }
|
||||||
|
.tile .share-tag { color: var(--yellow); }
|
||||||
|
.tile input[type=range] { width: 100%; accent-color: var(--brand); }
|
||||||
|
.tile .connecting { font-size: 11px; color: var(--yellow); }
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
|
||||||
|
font-weight: 700; color: #fff; flex-shrink: 0; user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* spatial plane */
|
||||||
|
#spatial-wrap {
|
||||||
|
height: 34vh; min-height: 220px; background: var(--bg-1);
|
||||||
|
border-top: 1px solid rgba(0,0,0,.35); display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.spatial-label {
|
||||||
|
padding: 8px 16px 0; font-size: 11px; font-weight: 700; color: var(--text-2);
|
||||||
|
letter-spacing: .5px; display: flex; gap: 16px;
|
||||||
|
}
|
||||||
|
.spatial-label .hint { font-weight: 400; letter-spacing: 0; text-transform: none; }
|
||||||
|
#spatial { flex: 1; width: 100%; cursor: grab; touch-action: none; }
|
||||||
|
#spatial.dragging { cursor: grabbing; }
|
||||||
|
|
||||||
|
/* modal */
|
||||||
|
#modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.7); display: grid;
|
||||||
|
place-items: center; z-index: 50;
|
||||||
|
}
|
||||||
|
#modal-backdrop.hidden { display: none; }
|
||||||
|
#settings-modal {
|
||||||
|
width: min(620px, 92vw); max-height: 90vh; overflow: auto;
|
||||||
|
background: var(--bg-1); border-radius: 8px; padding: 20px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
#settings-modal h2 { font-size: 18px; margin-bottom: 6px; }
|
||||||
|
#settings-modal h3 { font-size: 13px; margin: 16px 0 4px; color: var(--text-1); }
|
||||||
|
#settings-modal p { margin-bottom: 10px; }
|
||||||
|
#ice-json {
|
||||||
|
width: 100%; background: var(--bg-0); color: var(--text-0); border: 1px solid var(--bg-4);
|
||||||
|
border-radius: 6px; padding: 10px; font: 12px/1.5 ui-monospace, "Cascadia Code", monospace;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
#ice-json:focus { outline: none; border-color: var(--brand); }
|
||||||
|
#ice-server-default {
|
||||||
|
background: var(--bg-0); padding: 8px 10px; border-radius: 6px;
|
||||||
|
font: 11px/1.5 ui-monospace, monospace; white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.toggle-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; cursor: pointer; }
|
||||||
|
.toggle-row input { accent-color: var(--brand); width: 16px; height: 16px; }
|
||||||
|
.modal-actions { display: flex; gap: 8px; margin-top: 12px; align-items: center; }
|
||||||
|
.modal-actions .spacer { flex: 1; }
|
||||||
|
.modal-actions button {
|
||||||
|
padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer;
|
||||||
|
font-weight: 600; font-size: 13px;
|
||||||
|
}
|
||||||
|
.modal-actions button:not(.ghost) { background: var(--brand); color: #fff; }
|
||||||
|
.modal-actions button.ghost { background: transparent; color: var(--text-1); }
|
||||||
|
.modal-actions button.ghost:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* toasts */
|
||||||
|
#toasts {
|
||||||
|
position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column;
|
||||||
|
gap: 8px; z-index: 60; max-width: 380px;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-0); border-left: 3px solid var(--brand); border-radius: 6px;
|
||||||
|
padding: 10px 14px; box-shadow: 0 8px 20px rgba(0,0,0,.5); font-size: 13px;
|
||||||
|
animation: slide-in .18s ease-out;
|
||||||
|
}
|
||||||
|
.toast.error { border-left-color: var(--red); }
|
||||||
|
.toast.warn { border-left-color: var(--yellow); }
|
||||||
|
.toast.ok { border-left-color: var(--green); }
|
||||||
|
@keyframes slide-in { from { transform: translateX(30px); opacity: 0; } }
|
||||||
|
|
||||||
|
.hint { color: var(--text-2); font-size: 12px; }
|
||||||
|
code { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// Minimal WebRTC signaling + static file server for the Discord-clone POC.
|
||||||
|
// POC shortcut: no auth, no persistence, in-memory rooms, no rate limiting.
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 8080);
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ICE configuration: env-driven, full JSON override wins.
|
||||||
|
// STUN_URLS comma-separated stun: urls
|
||||||
|
// TURN_URLS comma-separated turn:/turns: urls
|
||||||
|
// TURN_USERNAME / TURN_CREDENTIAL static creds for all TURN urls
|
||||||
|
// ICE_SERVERS_JSON complete {"iceServers":[...]} override
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function iceConfig() {
|
||||||
|
if (process.env.ICE_SERVERS_JSON) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(process.env.ICE_SERVERS_JSON);
|
||||||
|
if (Array.isArray(parsed.iceServers)) return parsed;
|
||||||
|
console.error('ICE_SERVERS_JSON missing iceServers array, falling back to env vars');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('ICE_SERVERS_JSON invalid JSON:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const urls = (s) => (s || '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||||
|
const iceServers = urls(process.env.STUN_URLS || 'stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302')
|
||||||
|
.map((u) => ({ urls: u }));
|
||||||
|
const turnUrls = urls(process.env.TURN_URLS);
|
||||||
|
if (turnUrls.length) {
|
||||||
|
iceServers.push({
|
||||||
|
urls: turnUrls,
|
||||||
|
username: process.env.TURN_USERNAME || '',
|
||||||
|
credential: process.env.TURN_CREDENTIAL || process.env.TURN_PASSWORD || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { iceServers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static files
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
};
|
||||||
|
|
||||||
|
function serveStatic(req, res) {
|
||||||
|
const urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||||||
|
let rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||||
|
const file = path.normalize(path.join(PUBLIC_DIR, rel));
|
||||||
|
if (!file.startsWith(PUBLIC_DIR)) {
|
||||||
|
res.writeHead(403).end('forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.readFile(file, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.url.startsWith('/api/ice')) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(iceConfig()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
serveStatic(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Signaling
|
||||||
|
// client -> server: {type: offer|answer|candidate, to} {type: pos|state}
|
||||||
|
// server -> client: {type: hello, id, peers} {type: joined|left, ...}
|
||||||
|
// relayed messages get "from" stamped
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const rooms = new Map(); // roomId -> Map(peerId -> client)
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
function roomPeers(roomId) {
|
||||||
|
let room = rooms.get(roomId);
|
||||||
|
if (!room) {
|
||||||
|
room = new Map();
|
||||||
|
rooms.set(roomId, room);
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(room, msg, exceptId) {
|
||||||
|
const data = JSON.stringify(msg);
|
||||||
|
for (const [id, client] of room) {
|
||||||
|
if (id !== exceptId && client.socket.readyState === 1) client.socket.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.on('connection', (socket, req) => {
|
||||||
|
const q = new URL(req.url, 'http://x').searchParams;
|
||||||
|
const roomId = (q.get('room') || 'general').slice(0, 64);
|
||||||
|
const name = (q.get('name') || 'anon').slice(0, 32);
|
||||||
|
const id = crypto.randomBytes(4).toString('hex');
|
||||||
|
const room = roomPeers(roomId);
|
||||||
|
const client = { socket, id, name, x: 50, y: 50, state: { muted: false, deafened: false, sharing: false } };
|
||||||
|
room.set(id, client);
|
||||||
|
|
||||||
|
const send = (msg) => {
|
||||||
|
if (socket.readyState === 1) socket.send(JSON.stringify(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
send({ type: 'hello', id, room: roomId, peers: [...room.values()].filter((p) => p.id !== id).map(snapshot) });
|
||||||
|
broadcast(room, { type: 'joined', peer: snapshot(client) }, id);
|
||||||
|
console.log(`[signaling] ${name} (${id}) joined #${roomId} — ${room.size} in room`);
|
||||||
|
|
||||||
|
socket.on('message', (raw) => {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!msg || typeof msg.type !== 'string') return;
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'pos': {
|
||||||
|
const x = Number(msg.x), y = Number(msg.y);
|
||||||
|
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||||
|
client.x = Math.min(100, Math.max(0, x));
|
||||||
|
client.y = Math.min(100, Math.max(0, y));
|
||||||
|
broadcast(room, { type: 'pos', from: id, x: client.x, y: client.y }, id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'state': {
|
||||||
|
client.state = {
|
||||||
|
muted: !!msg.state?.muted,
|
||||||
|
deafened: !!msg.state?.deafened,
|
||||||
|
sharing: !!msg.state?.sharing,
|
||||||
|
};
|
||||||
|
broadcast(room, { type: 'state', from: id, state: client.state }, id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'offer':
|
||||||
|
case 'answer':
|
||||||
|
case 'candidate': {
|
||||||
|
if (!msg.to || !room.has(msg.to)) return;
|
||||||
|
const target = room.get(msg.to);
|
||||||
|
if (target.socket.readyState === 1) {
|
||||||
|
const { to: _to, ...rest } = msg;
|
||||||
|
target.socket.send(JSON.stringify({ ...rest, from: id }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break; // ignore unknown types — forward compat
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
room.delete(id);
|
||||||
|
if (room.size === 0) rooms.delete(roomId);
|
||||||
|
else broadcast(room, { type: 'left', id });
|
||||||
|
console.log(`[signaling] ${name} (${id}) left #${roomId} — ${room.size} in room`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function snapshot(c) {
|
||||||
|
return { id: c.id, name: c.name, x: c.x, y: c.y, state: c.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeat: drop dead sockets
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
for (const room of rooms.values()) {
|
||||||
|
for (const [, client] of room) {
|
||||||
|
if (client.socket.readyState === 1) {
|
||||||
|
client.socket.ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
wss.on('close', () => clearInterval(interval));
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
const ice = iceConfig();
|
||||||
|
console.log(`voice-poc listening on http://${HOST}:${PORT}`);
|
||||||
|
console.log(`ICE servers: ${JSON.stringify(ice.iceServers)}`);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user