MPP Basic Voice Chat

update checker, and a proper release

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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