update checker, and a proper release
// ==UserScript==
// @name MPP Basic Voice Chat
// @namespace https://multiplayerpiano.net/
// @version 1.1.0
// @description update checker, and a proper release
// @match https://multiplayerpiano.net/*
// @match https://dev.multiplayerpiano.net/*
// @match https://www.multiplayerpiano.net/*
// @match http://localhost/*
// @match http://127.0.0.1:3000/*
// @run-at document-end
// @grant unsafeWindow
// ==/UserScript==
window.__bvc_version = "1.1.0";
"use strict";
(() => {
// src/updates.js
async function checkForUpdates() {
const req = await fetch(
"https://git.sad.ovh/api/v1/repos/sophie/bvc/raw/dist/version.json"
);
const json = await req.json();
if (json.latest != window.__bvc_version) {
new MPP.Notification({
m: "notification",
duration: 15e3,
title: "Update Available",
html: `<p>A new Basic Voice Chat version has been released!</p><p>Local: v${window.__bvc_version}</p><p>Latest: v${json.latest}</p><a href='https://git.sad.ovh/sophie/bvc/raw/branch/main/dist/mpp-bvc.user.js' target='_blank' style='position: absolute; right: 0;bottom: 0; margin: 10px; font-size: 0.5rem;'>Click here to update.</a>`
});
}
}
// src/config.js
var constants = {
wasm_base: "https://git.sad.ovh/api/v1/repos/sophie/bvc/raw/dist/",
scope: "mpp-basic-voice-chat",
protocol_version: 2,
announce_interval: 5e3,
stale_timeout: 2e4,
user_audio_key: "mpv-user-audio",
cf_url: "https://rtc.live.cloudflare.com/v1/turn/keys/e8f41d5c86d95649c77a2e01afdde166/credentials/generate-ice-servers",
cf_token: "d5494966af3e0389eb1e42c51bdd4220d93e35b96c066a71c9963c51bfefbbb8",
ct_ttl: 86400,
fallback_servers: [{ urls: "stun:stun.cloudflare.com:3478" }]
};
var pct = (v) => Math.min(
100,
Math.max(0, (1 + Math.log10(Math.max(v, 1e-6)) / 2) * 100)
).toFixed(1);
// src/audio-engine.js
var AudioEngine = class {
constructor() {
this.ctx = null;
this._source = null;
this._localAnalyser = null;
this._gain = null;
this._outAnalyser = null;
this._outDest = null;
this._localBuf = null;
this._outBuf = null;
this.outStream = null;
this.localLevel = 0;
this.outLevel = 0;
this._denoiserNode = null;
this._wasmBytes = null;
this._workletAdded = false;
this._denoiserEnabled = true;
}
async unlock() {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return;
if (!this.ctx) this.ctx = new Ctor({ sampleRate: 48e3 });
await this._resume();
}
async _resume() {
if (this.ctx && this.ctx.state === "suspended") {
await this.ctx.resume().catch(() => {
});
}
}
async _ensureWorklet() {
if (this._workletAdded) return;
await this.ctx.audioWorklet.addModule(
constants.wasm_base + "/worklet.js"
);
this._workletAdded = true;
}
async _ensureWasm() {
if (this._wasmBytes) return this._wasmBytes;
const resp = await fetch(constants.wasm_base + "/wasm_nnnoiseless.wasm");
if (!resp.ok) throw new Error("Failed to fetch WASM: " + resp.status);
this._wasmBytes = await resp.arrayBuffer();
return this._wasmBytes;
}
async setupMic(stream) {
if (!this.ctx || this.ctx.state !== "running") return;
this._teardownMic();
this._source = this.ctx.createMediaStreamSource(stream);
this._localAnalyser = this.ctx.createAnalyser();
this._gain = this.ctx.createGain();
this._outAnalyser = this.ctx.createAnalyser();
this._outDest = this.ctx.createMediaStreamDestination();
this.outStream = this._outDest.stream;
this._localAnalyser.fftSize = 1024;
this._outAnalyser.fftSize = 1024;
this._localBuf = new Uint8Array(this._localAnalyser.fftSize);
this._outBuf = new Uint8Array(this._outAnalyser.fftSize);
this._source.connect(this._localAnalyser);
try {
await this._ensureWorklet();
this._denoiserNode = new AudioWorkletNode(
this.ctx,
"denoiser-processor"
);
const wasmBytes = await this._ensureWasm();
await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Denoiser init timeout")),
5e3
);
this._denoiserNode.port.onmessage = (e) => {
clearTimeout(timeout);
if (e.data.type === "ready") resolve();
else reject(new Error(e.data.msg));
};
this._denoiserNode.port.postMessage({ type: "init", wasmBytes }, [
wasmBytes
]);
this._wasmBytes = null;
});
if (!this._denoiserEnabled) {
this._denoiserNode.port.postMessage({
type: "setEnabled",
value: false
});
}
this._source.connect(this._denoiserNode);
this._denoiserNode.connect(this._gain);
} catch (err) {
console.warn("[VoiceChat] Denoiser unavailable, using raw mic:", err);
this._denoiserNode = null;
this._source.connect(this._gain);
}
this._gain.connect(this._outAnalyser);
this._outAnalyser.connect(this._outDest);
}
// rest of AudioEngine is unchanged...
async primeOutbound() {
if (!this.ctx || !this._outDest) return;
const tracks = this._outDest.stream.getAudioTracks();
if (!tracks.length || !tracks[0].muted) return;
const track = tracks[0];
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
gain.gain.value = 0;
osc.connect(gain);
gain.connect(this._outDest);
osc.start();
await new Promise((resolve) => {
let ms = 0;
const cleanup = () => {
try {
osc.stop();
} catch (_) {
}
osc.disconnect();
gain.disconnect();
};
track.addEventListener(
"unmute",
() => {
cleanup();
resolve();
},
{ once: true }
);
const poll = () => {
if (!track.muted) {
cleanup();
resolve();
return;
}
if (ms >= 500) {
cleanup();
resolve();
return;
}
ms += 20;
setTimeout(poll, 20);
};
setTimeout(poll, 20);
});
}
setMuted(muted) {
if (this._gain) this._gain.gain.value = muted ? 0 : 1;
}
setDenoiser(enabled) {
this._denoiserEnabled = enabled;
if (this._denoiserNode) {
this._denoiserNode.port.postMessage({
type: "setEnabled",
value: enabled
});
}
}
attachRemote(entry, stream) {
if (!entry.audio) {
entry.audio = document.createElement("audio");
entry.audio.autoplay = true;
entry.audio.playsInline = true;
document.body.appendChild(entry.audio);
}
if (entry.audio.srcObject !== stream) entry.audio.srcObject = stream;
if (this.ctx) {
this._attachRemoteOutput(entry, stream);
}
this.setRemoteAudio(entry, entry.localMuted, entry.volume);
if (!entry.outputGain) entry.audio.play().catch(() => {
});
if (!this.ctx) return;
const hook = () => {
if (!this.ctx) return;
if (entry.meterSource) {
try {
entry.meterSource.disconnect();
} catch (_) {
}
}
if (entry.meterAnalyser) {
try {
entry.meterAnalyser.disconnect();
} catch (_) {
}
}
const meterStream = stream.clone();
entry.meterSource = this.ctx.createMediaStreamSource(meterStream);
entry.meterAnalyser = this.ctx.createAnalyser();
entry.meterAnalyser.fftSize = 1024;
entry.meterBuf = new Uint8Array(entry.meterAnalyser.fftSize);
entry.meterSource.connect(entry.meterAnalyser);
this.ctx.resume().catch(() => {
});
};
const tracks = stream.getAudioTracks();
if (!tracks.length) return;
const track = tracks[0];
track.addEventListener("unmute", () => hook());
if (!track.muted) hook();
}
_attachRemoteOutput(entry, stream) {
this._disconnectRemoteOutput(entry);
entry.outputStream = stream.clone();
entry.outputSource = this.ctx.createMediaStreamSource(entry.outputStream);
entry.outputGain = this.ctx.createGain();
entry.outputSource.connect(entry.outputGain);
entry.outputGain.connect(this.ctx.destination);
}
_disconnectRemoteOutput(entry) {
if (entry.outputSource) {
try {
entry.outputSource.disconnect();
} catch (_) {
}
entry.outputSource = null;
}
if (entry.outputGain) {
try {
entry.outputGain.disconnect();
} catch (_) {
}
entry.outputGain = null;
}
if (entry.outputStream) {
try {
entry.outputStream.getTracks().forEach((track) => track.stop());
} catch (_) {
}
entry.outputStream = null;
}
}
setRemoteAudio(entry, muted, volume) {
const normalizedVolume = Math.min(2, Math.max(0, Number(volume) || 0));
entry.localMuted = Boolean(muted);
entry.volume = normalizedVolume;
if (entry.outputGain) {
entry.outputGain.gain.value = entry.localMuted ? 0 : normalizedVolume;
}
if (!entry.audio) return;
entry.audio.muted = Boolean(entry.outputGain) || entry.localMuted || normalizedVolume === 0;
entry.audio.volume = Math.min(1, normalizedVolume);
}
detachRemote(entry) {
this._disconnectRemoteOutput(entry);
if (entry.meterSource) {
try {
entry.meterSource.disconnect();
} catch (_) {
}
try {
entry.meterSource.mediaStream?.getTracks().forEach((t) => t.stop());
} catch (_) {
}
entry.meterSource = null;
}
if (entry.meterAnalyser) {
try {
entry.meterAnalyser.disconnect();
} catch (_) {
}
entry.meterAnalyser = null;
}
if (entry.audio) {
entry.audio.srcObject = null;
entry.audio.remove();
entry.audio = null;
}
}
refreshLevels(calls) {
this.localLevel = this._rms(this._localAnalyser, this._localBuf);
this.outLevel = this._rms(this._outAnalyser, this._outBuf);
for (const entry of calls.values()) {
entry.level = this._rms(entry.meterAnalyser, entry.meterBuf);
}
}
_rms(analyser, buf) {
if (!analyser || !buf) return 0;
analyser.getByteTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i++) {
const s = (buf[i] - 128) / 128;
sum += s * s;
}
return Math.sqrt(sum / buf.length);
}
playTestTone() {
if (!this.ctx) return;
this.ctx.resume().then(() => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const t = this.ctx.currentTime;
osc.frequency.value = 660;
gain.gain.setValueAtTime(1e-4, t);
gain.gain.exponentialRampToValueAtTime(0.15, t + 0.02);
gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.25);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.28);
}).catch(() => {
});
}
getOutboundStream(fallback) {
return this.outStream || fallback;
}
_teardownMic() {
for (const node of [
this._source,
this._localAnalyser,
this._denoiserNode,
this._gain,
this._outAnalyser
]) {
if (node) {
try {
node.disconnect();
} catch (_) {
}
}
}
this._source = this._localAnalyser = this._denoiserNode = this._gain = this._outAnalyser = this._outDest = null;
this.outStream = this._localBuf = this._outBuf = null;
this.localLevel = this.outLevel = 0;
}
teardown() {
this._teardownMic();
this._workletAdded = false;
if (this.ctx) {
this.ctx.close().catch(() => {
});
this.ctx = null;
}
}
};
// src/call-entry.js
var CallEntry = class {
constructor(participantId, direction, pc, sessionId) {
this.participantId = participantId;
this.direction = direction;
this.pc = pc;
this.sessionId = sessionId;
this.hasRemoteStream = false;
this.stream = null;
this.audio = null;
this.outputStream = null;
this.outputSource = null;
this.outputGain = null;
this.localMuted = false;
this.volume = 1;
this.meterSource = null;
this.meterAnalyser = null;
this.meterBuf = null;
this.level = 0;
this.latency = null;
this.iceInfo = null;
this.pendingIce = [];
this.remoteDescriptionSet = false;
this._pcBound = false;
this._closed = false;
}
getPeerConnection() {
return this.pc;
}
bindPeerConnection(onUpdate) {
const pc = this.pc;
if (!pc || this._pcBound) return;
this._pcBound = true;
for (const evt of ["iceconnectionstatechange", "connectionstatechange"]) {
pc.addEventListener(evt, onUpdate);
}
}
close() {
if (this._closed) return;
this._closed = true;
try {
this.pc.close();
} catch (_) {
}
}
async refreshStats() {
const pc = this.getPeerConnection();
if (!pc || typeof pc.getStats !== "function") return;
try {
const stats = await pc.getStats();
let rttMs = null;
let localCandidate = null;
let remoteCandidate = null;
const candidates = {};
stats.forEach((r) => {
if (r.type === "local-candidate") candidates["l:" + r.id] = r;
if (r.type === "remote-candidate") candidates["r:" + r.id] = r;
if (r.type === "candidate-pair" && r.state === "succeeded" && r.nominated) {
if (typeof r.currentRoundTripTime === "number")
rttMs = r.currentRoundTripTime * 1e3;
localCandidate = r.localCandidateId;
remoteCandidate = r.remoteCandidateId;
}
});
this.latency = rttMs !== null ? Math.round(rttMs) : null;
const lc = candidates["l:" + localCandidate];
const rc = candidates["r:" + remoteCandidate];
if (lc || rc) {
this.iceInfo = {
localType: lc ? lc.candidateType : "?",
localAddr: lc ? (lc.address || lc.ip || "?") + ":" + lc.port : "?",
localProto: lc ? lc.protocol : "?",
remoteType: rc ? rc.candidateType : "?",
remoteAddr: rc ? (rc.address || rc.ip || "?") + ":" + rc.port : "?",
remoteProto: rc ? rc.protocol : "?",
relayProto: lc ? lc.relayProtocol || null : null,
url: lc ? lc.url || null : null
};
}
} catch (_) {
}
}
};
// src/voice-panel.js
var VoicePanel = class {
constructor(onToggle, onMute, onTest, onDenoiseToggle, onUserMute, onUserVolume) {
this._onToggle = onToggle;
this._onMute = onMute;
this._onUserMute = onUserMute;
this._onUserVolume = onUserVolume;
this._userBars = /* @__PURE__ */ new Map();
this._meterValues = /* @__PURE__ */ new Map();
this._injectStyles();
this._root = document.createElement("div");
this._root.id = "mpv";
this._root.innerHTML = `
<div class="mpv-header">
<span class="mpv-title">Voice Chat</span>
<button class="mpv-collapse">−</button>
</div>
<div class="mpv-content">
<div class="mpv-row">
<button class="mpv-btn mpv-toggle">Voice Off</button>
<button class="mpv-btn mpv-mute" disabled>Mute</button>
<button class="mpv-btn mpv-test">Test</button>
</div>
<div class="mpv-status">Voice chat idle</div>
<div class="mpv-users"></div>
<div class="mpv-meters">
<div class="mpv-meter-row">
<span class="mpv-meter-label">MIC</span>
<div class="mpv-bar-track">
<div class="mpv-bar mpv-bar-mic"></div>
</div>
</div>
<div class="mpv-meter-row mpv-latency-row">
<span class="mpv-meter-label mpv-lat-label">LAT</span>
<span class="mpv-lat-value">—</span>
<div class="mpv-ice-tooltip"></div>
</div>
<div class="mpv-meter-row mpv-denoise-row">
<span class="mpv-meter-label" style="color:#888">DNS</span>
<button class="mpv-btn mpv-denoise-btn">RNNoise On</button>
</div>
</div>
</div>
`;
document.body.appendChild(this._root);
const savedPos = localStorage.getItem("mpv-position");
if (savedPos) {
try {
const pos = JSON.parse(savedPos);
this._root.style.left = pos.left + "px";
this._root.style.top = pos.top + "px";
this._root.style.right = "auto";
this._root.style.bottom = "auto";
} catch {
}
}
this._elToggle = this._root.querySelector(".mpv-toggle");
this._elMute = this._root.querySelector(".mpv-mute");
this._elStatus = this._root.querySelector(".mpv-status");
this._elUsers = this._root.querySelector(".mpv-users");
this._elBarMic = this._root.querySelector(".mpv-bar-mic");
this._elLatVal = this._root.querySelector(".mpv-lat-value");
this._elLatRow = this._root.querySelector(".mpv-latency-row");
this._elTooltip = this._root.querySelector(".mpv-ice-tooltip");
this._elDenoiseBtn = this._root.querySelector(".mpv-denoise-btn");
this._elToggle.addEventListener("click", () => this._onToggle());
this._elMute.addEventListener("click", () => this._onMute());
this._root.querySelector(".mpv-test").addEventListener("click", onTest);
this._elDenoiseBtn.addEventListener("click", () => onDenoiseToggle());
this._elLatRow.addEventListener("mouseenter", () => {
if (this._elTooltip.textContent)
this._elTooltip.style.display = "block";
});
this._elLatRow.addEventListener("mouseleave", () => {
this._elTooltip.style.display = "none";
});
this._header = this._root.querySelector(".mpv-header");
this._content = this._root.querySelector(".mpv-content");
this._collapseBtn = this._root.querySelector(".mpv-collapse");
this._collapsed = localStorage.getItem("mpv-collapsed") === "true";
if (this._collapsed) {
this._content.style.display = "none";
this._collapseBtn.textContent = "+";
}
this._collapseBtn.addEventListener("click", () => {
this._collapsed = !this._collapsed;
localStorage.setItem("mpv-collapsed", String(this._collapsed));
this._content.style.display = this._collapsed ? "none" : "";
this._collapseBtn.textContent = this._collapsed ? "+" : "−";
});
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
this._header.addEventListener("mousedown", (e) => {
dragging = true;
const rect = this._root.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
this._root.style.right = "auto";
this._root.style.bottom = "auto";
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!dragging) return;
this._root.style.left = startLeft + (e.clientX - startX) + "px";
this._root.style.top = startTop + (e.clientY - startY) + "px";
});
document.addEventListener("mouseup", () => {
if (dragging) {
localStorage.setItem(
"mpv-position",
JSON.stringify({
left: parseInt(this._root.style.left, 10) || 0,
top: parseInt(this._root.style.top, 10) || 0
})
);
}
dragging = false;
});
}
_injectStyles() {
const s = document.createElement("style");
s.textContent = `
#mpv {
position: fixed;
left: 20px;
top: 20px;
right: auto;
bottom: auto;
z-index: 1500;
width: 280px; padding: 8px; box-sizing: border-box;
background: rgba(17,17,17,.92); border: 1px solid #555;
border-radius: 4px; box-shadow: 2px 2px 8px rgba(0,0,0,.4);
font: 12px Verdana, sans-serif; color: #fff; text-shadow: 1px 1px #333;
}
.mpv-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: -8px -8px 8px;
padding: 6px 8px;
background: rgba(255,255,255,.06);
border-bottom: 1px solid #555;
cursor: move;
user-select: none;
}
.mpv-title {
font-weight: bold;
color: #ddd;
}
.mpv-collapse {
width: 20px;
height: 20px;
padding: 0;
background: #111;
border: 1px solid #555;
color: white;
cursor: pointer;
}
.mpv-collapse:hover {
background: #222;
}
#mpv .mpv-row { display: flex; gap: 4px; margin-bottom: 6px; }
.mpv-btn {
flex: 1; height: 24px; padding: 0 6px;
background: #111; border: 1px solid #555; border-radius: 2px;
color: #fff; font: inherit; cursor: pointer; white-space: nowrap;
}
.mpv-btn:hover { background: #222; }
.mpv-btn:disabled { opacity: .45; cursor: default; }
.mpv-toggle.on { background: #1e4d1e; }
.mpv-mute.muted { background: #5b2020; }
.mpv-status { margin-bottom: 5px; color: #ccc; font-size: 11px; }
.mpv-users { max-height: 180px; overflow: hidden auto; margin-bottom: 6px; }
.mpv-user {
display: flex; flex-direction: column; align-items: stretch;
padding: 5px 0; gap: 4px;
}
.mpv-user + .mpv-user { border-top: 1px solid rgba(255,255,255,.08); }
.mpv-user-head {
display: grid; grid-template-columns: minmax(42px, 1fr) auto 48px 110px;
align-items: center; gap: 4px; min-width: 0;
}
.mpv-uname { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mpv-upill {
flex: 0 0 auto; padding: 1px 5px; border-radius: 2px;
background: #2a2a2a; color: #bbb; font-size: 10px;
}
.mpv-upill.live { background: #1a3d1a; color: #7f7; }
.mpv-upill.local-muted { background: #3d2f1a; color: #f0c878; }
.mpv-user-mute {
flex: 0 0 auto; width: 48px; height: 18px; padding: 0 4px;
font-size: 10px; line-height: 16px;
}
.mpv-user-mute.muted { background: #5b2020; border-color: #844; color: #f99; }
.mpv-user-volume-wrap {
display: flex; align-items: center; gap: 5px; min-width: 0; white-space: nowrap;
}
.mpv-user-volume {
appearance: none; flex: 1 1 auto; min-width: 0; height: 14px; margin: 0; background: transparent;
}
.mpv-user-volume::-webkit-slider-runnable-track {
height: 4px; background: #333; border: 1px solid #555; border-radius: 2px;
}
.mpv-user-volume::-webkit-slider-thumb {
appearance: none; width: 8px; height: 12px; margin-top: -5px;
background: #b9c76a; border: 1px solid #222; border-radius: 1px;
}
.mpv-user-volume::-moz-range-track {
height: 4px; background: #333; border: 1px solid #555; border-radius: 2px;
}
.mpv-user-volume::-moz-range-progress {
height: 4px; background: #67783d; border-radius: 2px;
}
.mpv-user-volume::-moz-range-thumb {
width: 8px; height: 12px; background: #b9c76a; border: 1px solid #222; border-radius: 1px;
}
.mpv-user-volume-value {
flex: 0 0 32px; font-size: 10px; line-height: 14px; color: #aaa; text-align: right;
}
.mpv-user.local-muted .mpv-bar { opacity: .45; }
.mpv-meters { display: flex; flex-direction: column; gap: 4px; }
.mpv-meter-row { display: flex; align-items: center; gap: 6px; height: 14px; position: relative; }
.mpv-meter-label { width: 24px; font-size: 10px; color: #888; flex-shrink: 0; }
.mpv-bar-track {
flex: 1; height: 8px; background: #222; border-radius: 3px; overflow: hidden;
}
.mpv-user > .mpv-bar-track { flex: 0 0 8px; width: 100%; margin-top: 2px; }
.mpv-bar {
height: 100%; width: 0%; border-radius: 3px;
background: #69a85f;
transition: width 90ms linear;
}
.mpv-latency-row { cursor: default; }
.mpv-lat-label { color: #888; }
.mpv-lat-value { font-size: 11px; color: #aaa; margin-right: auto; }
.mpv-denoise-row { margin-top: 2px; }
.mpv-denoise-btn { flex: 1; font-size: 10px; height: 20px; }
.mpv-denoise-btn.off { background: #3d2020; color: #f88; border-color: #844; }
.mpv-ice-tooltip {
display: none; position: absolute; right: 0; bottom: calc(100% + 4px);
background: #1a1a1a; border: 1px solid #555; border-radius: 3px;
padding: 6px 8px; font-size: 10px; color: #ccc; white-space: pre;
z-index: 10; line-height: 1.5; text-shadow: none; pointer-events: none;
min-width: 180px;
}`;
document.head.appendChild(s);
}
setEnabled(on) {
this._elToggle.textContent = on ? "Voice On" : "Voice Off";
this._elToggle.classList.toggle("on", on);
this._elMute.disabled = !on;
}
setMuted(muted) {
this._elMute.textContent = muted ? "Unmute" : "Mute";
this._elMute.classList.toggle("muted", muted);
}
setDenoiser(enabled) {
this._elDenoiseBtn.textContent = enabled ? "RNNoise On" : "RNNoise Off";
this._elDenoiseBtn.classList.toggle("off", !enabled);
}
setStatus(text) {
this._elStatus.textContent = text;
}
renderUsers(users, calls) {
this._elUsers.innerHTML = "";
this._userBars.clear();
const sorted = [...users.values()].sort(
(a, b) => a.name.localeCompare(b.name)
);
const visibleIds = /* @__PURE__ */ new Set();
for (const u of sorted) {
visibleIds.add(u.id);
const hasStream = calls.has(u.id + ":in") && calls.get(u.id + ":in").hasRemoteStream || calls.has(u.id + ":out") && calls.get(u.id + ":out").hasRemoteStream;
const localMuted = Boolean(u.localMuted);
const volume = Math.min(2, Math.max(0.01, Number(u.volume) || 1));
const row = document.createElement("div");
row.className = "mpv-user" + (localMuted ? " local-muted" : "");
row.dataset.id = u.id;
const head = document.createElement("div");
head.className = "mpv-user-head";
const name = document.createElement("span");
name.className = "mpv-uname";
name.textContent = u.name;
name.style.color = u.color;
const pill = document.createElement("span");
pill.className = "mpv-upill" + (localMuted ? " local-muted" : hasStream && !u.muted ? " live" : "");
pill.textContent = localMuted ? "local" : u.muted ? "muted" : hasStream ? "live" : "ready";
const mute = document.createElement("button");
mute.type = "button";
mute.className = "mpv-btn mpv-user-mute" + (localMuted ? " muted" : "");
mute.textContent = localMuted ? "Hear" : "Mute";
mute.title = localMuted ? "Unmute user locally" : "Mute user locally";
mute.addEventListener("click", () => this._onUserMute(u.id, !localMuted));
const track = document.createElement("div");
track.className = "mpv-bar-track";
const bar = document.createElement("div");
bar.className = "mpv-bar";
bar.style.width = (this._meterValues.get("user:" + u.id) || 0).toFixed(1) + "%";
this._userBars.set(u.id, bar);
track.append(bar);
const slider = document.createElement("input");
slider.type = "range";
slider.className = "mpv-user-volume";
slider.min = "0.5";
slider.max = "100";
slider.step = "0.5";
slider.value = String(this._sliderValueForVolume(volume));
slider.title = "User volume";
const volumeValue = document.createElement("span");
volumeValue.className = "mpv-user-volume-value";
volumeValue.textContent = this._volumePercentForSliderValue(slider.value) + "%";
slider.addEventListener("input", () => {
volumeValue.textContent = this._volumePercentForSliderValue(slider.value) + "%";
this._onUserVolume(u.id, this._volumeForSliderValue(slider.value));
});
const volumeWrap = document.createElement("div");
volumeWrap.className = "mpv-user-volume-wrap";
volumeWrap.append(slider, volumeValue);
head.append(name, pill, mute, volumeWrap);
row.append(head, track);
this._elUsers.appendChild(row);
}
for (const key of [...this._meterValues.keys()]) {
if (key.startsWith("user:") && !visibleIds.has(key.slice(5))) {
this._meterValues.delete(key);
}
}
}
_sliderValueForVolume(volume) {
const percent = Math.round(
Math.min(2, Math.max(0.01, Number(volume) || 1)) * 100
);
if (percent >= 200) return 100;
return Math.min(99.5, Math.max(0.5, percent / 2));
}
_volumePercentForSliderValue(value) {
const sliderValue = Math.min(100, Math.max(0.5, Number(value) || 50));
if (sliderValue >= 100) return 200;
return Math.min(199, Math.max(1, Math.round(sliderValue * 2)));
}
_volumeForSliderValue(value) {
return this._volumePercentForSliderValue(value) / 100;
}
updateMeters(micLevel, calls) {
this._setSmoothBar("mic", this._elBarMic, Number(pct(micLevel)));
for (const [id, bar] of this._userBars.entries()) {
const call = calls.get(id + ":out") || calls.get(id + ":in");
this._setSmoothBar("user:" + id, bar, call ? Number(pct(call.level)) : 0);
}
}
_setSmoothBar(key, bar, target) {
if (!bar) return;
const clampedTarget = Math.min(100, Math.max(0, target || 0));
const current = this._meterValues.has(key) ? this._meterValues.get(key) : 0;
const factor = clampedTarget > current ? 0.45 : 0.16;
let next = current + (clampedTarget - current) * factor;
if (clampedTarget === 0 && next < 0.3) next = 0;
this._meterValues.set(key, next);
bar.style.width = next.toFixed(1) + "%";
}
updateLatency(calls) {
let best = null;
let iceInfo = null;
for (const e of calls.values()) {
if (e.latency !== null && (best === null || e.latency < best)) {
best = e.latency;
iceInfo = e.iceInfo;
}
}
this._elLatVal.textContent = best !== null ? best + " ms" : "—";
this._elLatVal.style.color = best === null ? "#666" : best < 80 ? "#7f7" : best < 150 ? "#cc9" : "#f77";
if (iceInfo) {
this._elTooltip.textContent = `Local: ${iceInfo.localType} ${iceInfo.localAddr} (${iceInfo.localProto})
Remote: ${iceInfo.remoteType} ${iceInfo.remoteAddr} (${iceInfo.remoteProto})` + (iceInfo.relayProto ? `
Relay: ${iceInfo.relayProto}` : "") + (iceInfo.url ? `
URL: ${iceInfo.url}` : "");
} else {
this._elTooltip.textContent = "";
}
}
};
// src/voice-chat.js
var VoiceChat = class {
constructor(mppClient) {
this._client = mppClient;
this._enabled = false;
this._muted = localStorage.getItem("mpv-muted") === "true";
this._localStream = null;
this._sessionId = "";
this._iceServers = constants.fallback_servers;
this._roomId = this._getRoomId();
this._users = /* @__PURE__ */ new Map();
this._calls = /* @__PURE__ */ new Map();
this._pendingIce = /* @__PURE__ */ new Map();
this._announceTimer = 0;
this._cleanupTimer = 0;
this._statsTimer = 0;
this._meterFrame = 0;
this._userAudioSettings = this._loadUserAudioSettings();
this._audio = new AudioEngine();
this._denoiserEnabled = localStorage.getItem("mpv-denoise") !== "false";
this._audio.setDenoiser(this._denoiserEnabled);
this._panel = new VoicePanel(
() => this._enabled ? this._stop() : this._start(),
() => this._setMuted(!this._muted),
async () => {
await this._audio.unlock();
this._audio.playTestTone();
},
() => this._toggleDenoiser(),
(id, muted) => this._setUserMuted(id, muted),
(id, volume) => this._setUserVolume(id, volume)
);
this._panel.setDenoiser(this._denoiserEnabled);
this._bindClientEvents();
this._cleanupTimer = setInterval(() => this._cleanupStale(), 5e3);
}
// ---- client event wiring -----------------------------------------------
_bindClientEvents() {
this._client.on("custom", (msg) => this._onCustomMessage(msg));
this._client.on("participant removed", (p) => {
if (!p?.id) return;
this._closeCall(p.id);
this._users.delete(p.id);
this._render();
});
this._client.on("ch", () => {
const next = this._getRoomId();
if (next === this._roomId) return;
this._roomId = next;
this._closeAllCalls();
this._users.clear();
if (this._enabled) this._announce();
this._render();
});
this._client.on("disconnect", () => {
this._closeAllCalls();
this._users.clear();
this._render();
});
window.addEventListener("beforeunload", () => {
if (this._enabled) this._sendState({ enabled: false, muted: true });
});
}
// ---- voice lifecycle ----------------------------------------------------
async _fetchIceServers() {
try {
const resp = await fetch(constants.cf_url, {
method: "POST",
headers: {
Authorization: "Bearer " + constants.cf_token,
"Content-Type": "application/json"
},
body: JSON.stringify({ ttl: constants.ct_ttl })
});
if (!resp.ok) throw new Error("HTTP " + resp.status);
const data = await resp.json();
if (Array.isArray(data?.iceServers) && data.iceServers.length) {
this._iceServers = data.iceServers;
} else {
throw new Error("Empty iceServers in response");
}
} catch (err) {
console.warn(
"[VoiceChat] Failed to fetch Cloudflare TURN credentials, using fallback STUN:",
err
);
this._iceServers = constants.fallback_servers;
}
}
async _start() {
if (!this._supported()) {
this._panel.setStatus("WebRTC not supported in this browser.");
return;
}
await this._audio.unlock();
await this._fetchIceServers();
try {
this._localStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
latency: 0
},
video: false
});
} catch {
this._panel.setStatus("Microphone permission denied.");
return;
}
await this._audio._resume();
await this._audio.setupMic(this._localStream);
await this._audio.primeOutbound();
this._enabled = true;
this._audio.setMuted(this._muted);
this._localStream.getAudioTracks().forEach((t) => t.enabled = !this._muted);
this._sessionId = this._makeSessionId();
this._announce();
clearInterval(this._announceTimer);
this._announceTimer = setInterval(
() => this._announce(),
constants.announce_interval
);
this._connectToKnown();
this._startStatsTimer();
this._render();
}
_stop() {
if (!this._enabled) return;
this._sendState({ enabled: false, muted: true });
this._enabled = false;
this._muted = false;
clearInterval(this._announceTimer);
this._announceTimer = 0;
this._closeAllCalls();
this._sessionId = "";
this._pendingIce.clear();
this._localStream?.getTracks().forEach((t) => t.stop());
this._localStream = null;
this._audio.teardown();
this._stopStatsTimer();
this._render();
}
_setMuted(muted, announce = true) {
this._muted = muted;
localStorage.setItem("mpv-muted", String(muted));
this._localStream?.getAudioTracks().forEach((t) => t.enabled = !muted);
this._audio.setMuted(muted);
if (announce) this._announce();
this._render();
}
_toggleDenoiser() {
this._denoiserEnabled = !this._denoiserEnabled;
localStorage.setItem("mpv-denoise", String(this._denoiserEnabled));
this._audio.setDenoiser(this._denoiserEnabled);
this._panel.setDenoiser(this._denoiserEnabled);
}
// ---- session management -------------------------------------------------
_makeSessionId() {
const bytes = new Uint8Array(12);
if (crypto?.getRandomValues) {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i++)
bytes[i] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
// ---- signalling ---------------------------------------------------------
_announce() {
if (!this._enabled || !this._sessionId) return;
this._sendState({
enabled: true,
muted: this._muted
});
}
_connectToKnown() {
for (const u of this._users.values()) {
if (u.enabled) this._connectToUser(u.id);
}
}
async _connectToUser(id) {
if (!this._enabled || !this._sessionId) return;
const outbound = this._audio.getOutboundStream(this._localStream);
if (!outbound) return;
const u = this._users.get(id);
if (!u?.enabled || !u.sessionId) return;
if (!this._shouldCall(id)) return;
const key = id + ":out";
const existing = this._calls.get(key);
if (existing?.sessionId === u.sessionId) return;
if (existing) this._closeCallKey(key, false);
let entry = null;
try {
await this._audio.primeOutbound();
entry = this._createCall(id, u.sessionId, "out");
const offer = await entry.pc.createOffer({
offerToReceiveAudio: true
});
await entry.pc.setLocalDescription(offer);
this._sendSignal(id, "offer", {
description: this._packDescription(entry.pc.localDescription)
});
} catch (_) {
if (entry && this._calls.get(key) === entry)
this._closeCallKey(key, false);
}
}
async _onOffer(data) {
if (!this._enabled || !this._sessionId || !data.description || !data.sessionId) {
return;
}
const outbound = this._audio.getOutboundStream(this._localStream);
if (!outbound) return;
if (this._shouldCall(data.from)) return;
await this._audio.primeOutbound();
const id = data.from;
const existingUser = this._users.get(id);
if (existingUser?.sessionId && existingUser.sessionId !== data.sessionId)
this._closeCall(id, false);
this._upsertUser({
id,
name: data.name || this._pName(id),
color: data.color || this._pColor(id),
sessionId: data.sessionId,
enabled: true,
muted: Boolean(data.muted),
lastSeen: Date.now()
});
const key = id + ":in";
let entry = this._calls.get(key);
if (entry && entry.sessionId !== data.sessionId) {
this._closeCallKey(key, false);
entry = null;
}
if (!entry) entry = this._createCall(id, data.sessionId, "in");
try {
await entry.pc.setRemoteDescription(data.description);
entry.remoteDescriptionSet = true;
await this._flushPendingIce(entry);
const answer = await entry.pc.createAnswer();
await entry.pc.setLocalDescription(answer);
this._sendSignal(id, "answer", {
description: this._packDescription(entry.pc.localDescription)
});
} catch (_) {
this._closeCallKey(key, false);
}
this._render();
}
async _onAnswer(data) {
if (!this._enabled || !this._sessionId || !data.description || !data.sessionId) {
return;
}
const entry = this._findCallForSignal(data.from, data.sessionId);
if (!entry || entry.direction !== "out") return;
try {
await entry.pc.setRemoteDescription(data.description);
entry.remoteDescriptionSet = true;
await this._flushPendingIce(entry);
} catch (_) {
this._closeCallKey(this._keyForEntry(entry), false);
}
this._render();
}
async _onIce(data) {
if (!this._enabled || !this._sessionId || !data.candidate || !data.sessionId) {
return;
}
const entry = this._findCallForSignal(data.from, data.sessionId);
if (!entry) {
this._queueRemoteIce(data.from, data.sessionId, data.candidate);
return;
}
await this._addRemoteIce(entry, data.candidate);
}
_onHangup(data) {
const entry = this._findCallForSignal(data.from, data.sessionId);
if (entry) this._closeCallKey(this._keyForEntry(entry), false);
}
_createCall(participantId, sessionId, direction) {
const key = participantId + ":" + direction;
const PeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection;
const pc = new PeerConnection({
iceServers: this._iceServers,
iceTransportPolicy: "all"
});
const entry = new CallEntry(participantId, direction, pc, sessionId);
this._applyUserAudioSettings(participantId, entry);
this._calls.set(key, entry);
entry.bindPeerConnection(() => this._render());
const outbound = this._audio.getOutboundStream(this._localStream);
if (outbound) {
outbound.getTracks().forEach((track) => pc.addTrack(track, outbound));
}
pc.addEventListener(
"track",
(event) => this._onRemoteTrack(entry, event)
);
pc.addEventListener("icecandidate", (event) => {
if (!event.candidate) return;
this._sendSignal(participantId, "ice", {
candidate: this._packCandidate(event.candidate)
});
});
pc.addEventListener("connectionstatechange", () => {
if (this._calls.get(key) !== entry) return;
if (pc.connectionState === "failed") this._closeCallKey(key);
else this._render();
});
pc.addEventListener("iceconnectionstatechange", () => {
if (this._calls.get(key) !== entry) return;
if (pc.iceConnectionState === "failed") this._closeCallKey(key);
else this._render();
});
this._drainQueuedIce(entry);
this._render();
return entry;
}
_onRemoteTrack(entry, event) {
let stream = event.streams[0];
if (!stream) {
stream = entry.stream || new MediaStream();
if (!stream.getTracks().includes(event.track)) {
stream.addTrack(event.track);
}
}
entry.hasRemoteStream = true;
entry.stream = stream;
this._applyUserAudioSettings(entry.participantId, entry);
this._audio.attachRemote(entry, stream);
this._startStatsTimer();
this._render();
}
_findCallForSignal(participantId, sessionId) {
for (const entry of this._calls.values()) {
if (entry.participantId === participantId && entry.sessionId === sessionId) {
return entry;
}
}
return null;
}
_keyForEntry(entry) {
return entry.participantId + ":" + entry.direction;
}
_queueRemoteIce(participantId, sessionId, candidate) {
const key = participantId + ":" + sessionId;
const pending = this._pendingIce.get(key) || [];
pending.push(candidate);
this._pendingIce.set(key, pending);
}
_drainQueuedIce(entry) {
const key = entry.participantId + ":" + entry.sessionId;
const pending = this._pendingIce.get(key);
if (!pending?.length) return;
this._pendingIce.delete(key);
entry.pendingIce.push(...pending);
this._flushPendingIce(entry);
}
async _addRemoteIce(entry, candidate) {
if (!entry.remoteDescriptionSet && !entry.pc.remoteDescription) {
entry.pendingIce.push(candidate);
return;
}
try {
await entry.pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (_) {
}
}
async _flushPendingIce(entry) {
if (!entry.remoteDescriptionSet && !entry.pc.remoteDescription) return;
const pending = entry.pendingIce.splice(0);
for (const candidate of pending) {
await this._addRemoteIce(entry, candidate);
}
}
_packDescription(description) {
return description ? { type: description.type, sdp: description.sdp } : null;
}
_packCandidate(candidate) {
if (typeof candidate.toJSON === "function") return candidate.toJSON();
return {
candidate: candidate.candidate,
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex,
usernameFragment: candidate.usernameFragment
};
}
_closeCall(id, notify = true) {
for (const [k, e] of this._calls.entries()) {
if (e.participantId === id) this._closeCallKey(k, notify);
}
}
_closeCallKey(key, notify = true) {
const e = this._calls.get(key);
if (!e) return;
this._audio.detachRemote(e);
this._calls.delete(key);
if (notify && this._enabled && this._sessionId) {
this._sendSignal(e.participantId, "hangup", {
remoteSessionId: e.sessionId
});
}
e.close();
this._render();
}
_closeAllCalls() {
for (const k of [...this._calls.keys()]) this._closeCallKey(k);
}
// ---- messaging ----------------------------------------------------------
_onCustomMessage(msg) {
const data = this._parsePayload(msg);
if (!data || data.scope !== constants.scope || data.version !== constants.protocol_version)
return;
if (data.room && data.room !== this._roomId) return;
if (data.to && data.to !== this._ownId()) return;
if (!data.from || data.from === this._ownId()) return;
if (data.type !== "state" && data.remoteSessionId && data.remoteSessionId !== this._sessionId) {
return;
}
if (data.type === "state") this._onRemoteState(data);
else if (data.type === "offer") this._onOffer(data);
else if (data.type === "answer") this._onAnswer(data);
else if (data.type === "ice") this._onIce(data);
else if (data.type === "hangup") this._onHangup(data);
}
_parsePayload(msg) {
let d = msg?.data || msg?.payload || msg?.value || msg;
if (typeof d === "string") {
try {
d = JSON.parse(d);
} catch {
return null;
}
}
return d && typeof d === "object" ? d : null;
}
_onRemoteState(data) {
const user = {
id: data.from,
name: data.name || this._pName(data.from),
color: data.color || this._pColor(data.from),
sessionId: data.sessionId || "",
enabled: Boolean(data.enabled && data.sessionId),
muted: Boolean(data.muted),
lastSeen: Date.now()
};
if (!user.enabled) {
this._users.delete(user.id);
this._closeCall(user.id, false);
this._render();
return;
}
const existing = this._users.get(user.id);
if (existing?.sessionId !== user.sessionId)
this._closeCall(user.id, false);
this._upsertUser(user);
if (this._enabled) {
this._connectToUser(user.id);
if (data.reply !== false) this._sendStateReply(user.id);
}
this._render();
}
_sendState(payload) {
this._sendCustom(
Object.assign(
{
type: "state",
ts: Date.now(),
sessionId: this._sessionId,
enabled: this._enabled,
muted: this._muted
},
payload
)
);
}
_sendSignal(to, type, payload = {}) {
if (!this._enabled || !this._sessionId) return;
const user = this._users.get(to);
this._sendCustom(
Object.assign(
{
type,
to,
sessionId: this._sessionId,
remoteSessionId: user?.sessionId || payload.remoteSessionId || "",
ts: Date.now()
},
payload
)
);
}
_sendCustom(payload) {
const id = this._ownId();
if (!id || !this._client) return;
if (typeof this._client.isConnected === "function" && !this._client.isConnected()) {
return;
}
this._client.sendArray([
{
m: "custom",
data: Object.assign(this._localMeta(), payload),
target: { mode: "subscribed", global: false }
}
]);
}
_sendStateReply(to) {
if (!this._enabled || !this._sessionId) return;
this._sendState({
to,
reply: false,
enabled: true,
muted: this._muted
});
}
// ---- stats & meters -----------------------------------------------------
_startStatsTimer() {
if (!this._meterFrame) {
this._meterFrame = requestAnimationFrame(() => this._tickMeters());
}
if (!this._statsTimer) {
this._tickStats();
this._statsTimer = setInterval(() => this._tickStats(), 500);
}
}
_stopStatsTimer() {
clearInterval(this._statsTimer);
this._statsTimer = 0;
if (this._meterFrame) {
cancelAnimationFrame(this._meterFrame);
this._meterFrame = 0;
}
this._panel.updateMeters(0, this._calls);
}
_tickMeters() {
this._meterFrame = 0;
if (!this._enabled) {
this._panel.updateMeters(0, this._calls);
return;
}
this._audio.refreshLevels(this._calls);
this._panel.updateMeters(this._audio.localLevel, this._calls);
this._meterFrame = requestAnimationFrame(() => this._tickMeters());
}
_tickStats() {
for (const e of this._calls.values()) e.refreshStats();
this._panel.updateLatency(this._calls);
}
// ---- render -------------------------------------------------------------
_render() {
this._panel.setEnabled(this._enabled);
this._panel.setMuted(this._muted);
const connected = [...this._calls.values()].filter(
(e) => e.hasRemoteStream
).length;
const total = [...this._users.values()].filter((u) => u.enabled).length;
if (!this._enabled) this._panel.setStatus("Voice chat idle");
else if (!this._sessionId) this._panel.setStatus("Connecting…");
else if (total === 0)
this._panel.setStatus(
this._muted ? "Muted — no voice users" : "Waiting for voice users"
);
else
this._panel.setStatus(
`${this._muted ? "Muted" : "Live"} — ${connected}/${total} connected`
);
this._panel.renderUsers(this._users, this._calls);
}
// ---- helpers ------------------------------------------------------------
_cleanupStale() {
const cutoff = Date.now() - constants.stale_timeout;
for (const u of [...this._users.values()]) {
if (u.lastSeen < cutoff) {
this._users.delete(u.id);
this._closeCall(u.id);
}
}
this._render();
}
_loadUserAudioSettings() {
try {
const parsed = JSON.parse(
localStorage.getItem(constants.user_audio_key) || "{}"
);
if (!parsed || typeof parsed !== "object") return /* @__PURE__ */ new Map();
return new Map(
Object.entries(parsed).map(([id, settings]) => [
id,
this._normalizeUserAudioSettings(settings)
])
);
} catch {
return /* @__PURE__ */ new Map();
}
}
_saveUserAudioSettings() {
const data = {};
for (const [id, settings] of this._userAudioSettings.entries()) {
data[id] = settings;
}
try {
localStorage.setItem(constants.user_audio_key, JSON.stringify(data));
} catch (_) {
}
}
_normalizeUserAudioSettings(settings = {}) {
const rawVolume = Number(settings.volume);
return {
muted: Boolean(settings.muted),
volume: Number.isFinite(rawVolume) ? Math.min(2, Math.max(0.01, rawVolume)) : 1
};
}
_getUserAudioSettings(id) {
return this._userAudioSettings.get(this._participantAudioKey(id)) || {
muted: false,
volume: 1
};
}
_setUserMuted(id, muted) {
const settings = this._getUserAudioSettings(id);
this._setUserAudioSettings(id, {
muted,
volume: settings.volume
});
}
_setUserVolume(id, volume) {
const settings = this._getUserAudioSettings(id);
this._setUserAudioSettings(
id,
{
muted: settings.muted,
volume
},
false
);
}
_setUserAudioSettings(id, settings, render = true) {
const participantId = this._participantAudioKey(id);
if (!participantId) return;
const normalized = this._normalizeUserAudioSettings(settings);
this._userAudioSettings.set(participantId, normalized);
this._saveUserAudioSettings();
const user = this._users.get(id) || this._users.get(participantId);
if (user) {
user.localMuted = normalized.muted;
user.volume = normalized.volume;
}
this._applyUserAudioSettings(participantId);
if (render) this._render();
}
_applyUserAudioSettings(id, specificEntry = null) {
const participantId = this._participantAudioKey(id);
const settings = this._getUserAudioSettings(id);
const entries = specificEntry ? [specificEntry] : [...this._calls.values()].filter(
(entry) => this._participantAudioKey(entry.participantId) === participantId
);
for (const entry of entries) {
this._audio.setRemoteAudio(entry, settings.muted, settings.volume);
}
}
_participantAudioKey(participantId) {
return participantId === null || participantId === void 0 ? "" : String(participantId);
}
_upsertUser(u) {
const settings = this._getUserAudioSettings(u.id);
this._users.set(
u.id,
Object.assign({}, this._users.get(u.id), u, {
localMuted: settings.muted,
volume: settings.volume
})
);
}
_shouldCall(id) {
const own = this._ownId();
return Boolean(own && id && own < id);
}
_localMeta() {
const p = this._client.getOwnParticipant?.();
return {
scope: constants.scope,
version: constants.protocol_version,
room: this._roomId,
from: this._ownId(),
participantId: this._ownId(),
name: p?.name || "Anonymous",
color: p?.color || "#777"
};
}
_ownId() {
return this._client.participantId;
}
_pName(id) {
return this._client.ppl?.[id]?.name || "Unknown";
}
_pColor(id) {
return this._client.ppl?.[id]?.color || "#777";
}
_getRoomId() {
return this._client.channel?._id || this._client.desiredChannelId || location.pathname.replace(/^\//, "") || "lobby";
}
_supported() {
const PeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection;
return Boolean(PeerConnection && navigator.mediaDevices?.getUserMedia);
}
};
// src/main.js
var page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
(async () => {
await checkForUpdates();
})();
function waitForMPP() {
const c = page.MPP?.client;
if (c && typeof c.on === "function" && typeof c.sendArray === "function") {
new VoiceChat(c);
return;
}
setTimeout(waitForMPP, 250);
}
waitForMPP();
})();