MPP Basic Voice Chat

update checker, and a proper release

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==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();
})();