aboutsummaryrefslogtreecommitdiffstats
path: root/static
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-10-29 19:58:05 -0700
committerPinapelz <yukais@pinapelz.com>2026-10-29 19:58:05 -0700
commit96bfe2e1505b4ae250f2d5201f9541ceb01cec84 (patch)
treeb2164a76dc3aa5b69e160d1135d3385bcbe6108d /static
parent150858067059ba615c548f826bb343589993b4bb (diff)
ts web slopify code
Diffstat (limited to 'static')
-rw-r--r--static/mic-worklet.js60
-rw-r--r--static/voice.js1145
2 files changed, 1205 insertions, 0 deletions
diff --git a/static/mic-worklet.js b/static/mic-worklet.js
new file mode 100644
index 0000000..814bf6a
--- /dev/null
+++ b/static/mic-worklet.js
@@ -0,0 +1,60 @@
+// Microphone capture worklet: resamples the context rate to 48 kHz mono and
+// posts 20 ms frames (960 Float32 samples) together with their RMS level.
+const OUT_RATE = 48000;
+const FRAME_SAMPLES = 960;
+
+class Ts6MicCapture extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.ratio = sampleRate / OUT_RATE; // input samples per output sample
+ this.t = 0; // fractional read position; -1 refers to `last`
+ this.last = 0;
+ this.frame = new Float32Array(FRAME_SAMPLES);
+ this.offset = 0;
+ }
+
+ push(sample) {
+ this.frame[this.offset++] = sample;
+ if (this.offset < FRAME_SAMPLES) return;
+ let sum = 0;
+ for (let i = 0; i < FRAME_SAMPLES; i++) sum += this.frame[i] * this.frame[i];
+ const frame = this.frame;
+ this.frame = new Float32Array(FRAME_SAMPLES);
+ this.offset = 0;
+ this.port.postMessage({ samples: frame, rms: Math.sqrt(sum / FRAME_SAMPLES) }, [frame.buffer]);
+ }
+
+ process(inputs, outputs) {
+ const output = outputs[0] && outputs[0][0];
+ if (output) output.fill(0);
+ const channels = inputs[0];
+ if (!channels || channels.length === 0) return true;
+
+ // Downmix to mono.
+ let input = channels[0];
+ if (channels.length > 1) {
+ input = new Float32Array(input.length);
+ for (const ch of channels) for (let i = 0; i < input.length; i++) input[i] += ch[i] / channels.length;
+ }
+
+ if (this.ratio === 1) {
+ for (let i = 0; i < input.length; i++) this.push(input[i]);
+ return true;
+ }
+ // Linear interpolation resampler.
+ const len = input.length;
+ while (this.t < len - 1) {
+ const i0 = Math.floor(this.t);
+ const frac = this.t - i0;
+ const a = i0 < 0 ? this.last : input[i0];
+ const b = input[i0 + 1];
+ this.push(a + (b - a) * frac);
+ this.t += this.ratio;
+ }
+ this.t -= len;
+ this.last = input[len - 1];
+ return true;
+ }
+}
+
+registerProcessor("ts6-mic-capture", Ts6MicCapture);
diff --git a/static/voice.js b/static/voice.js
new file mode 100644
index 0000000..9cfa74b
--- /dev/null
+++ b/static/voice.js
@@ -0,0 +1,1145 @@
+// Browser voice client for the TeamSpeak dashboard.
+// Talks to the Node gateway over /ws/voice (see gateway/src/voice-session.js
+// for the wire protocol). No WebRTC: mic PCM goes up, Opus frames come down.
+//
+// UI layout:
+// - Top bar: shows current identity on the left and a [ browser connect ] button
+// on the right that opens a modal for [ nickname ] and [ server password ]
+// (both saved in localStorage).
+// - Each channel card has a [ join ] button; while connected, non-self users
+// show a per-user volume slider and a [ msg ] button for direct messages,
+// and the joined channel expands an inline chat box (with tabs for #channel
+// and @user direct messages) directly underneath its user list.
+// - Fixed bottom bar: mute / deafen / settings / leave controls.
+(() => {
+ "use strict";
+
+ const cfgEl = document.getElementById("voice-config");
+ const topbar = document.getElementById("voice-topbar");
+ const dialog = document.getElementById("voice-dialog");
+ if (!cfgEl || !topbar || !dialog) return;
+ const cfg = JSON.parse(cfgEl.textContent || "{}");
+ if (!cfg.enabled) return;
+
+ const $ = (id) => document.getElementById(id);
+ const ui = {
+ topbar,
+ topbarIdentity: $("voice-topbar-identity"),
+ loginBtn: $("voice-login-btn"),
+ dialog,
+ dialogTarget: $("voice-dialog-target"),
+ dialogMessage: $("voice-dialog-message"),
+ form: $("voice-join-form"),
+ nickname: $("voice-nickname"),
+ password: $("voice-password"),
+ submit: $("voice-submit"),
+ cancel: $("voice-cancel"),
+ message: $("voice-message"),
+ notice: $("voice-notice"),
+ bar: $("voice-bar"),
+ barMessage: $("voice-bar-message"),
+ channel: $("voice-channel"),
+ self: $("voice-self"),
+ meter: $("voice-meter"),
+ threshold: $("voice-threshold"),
+ mute: $("voice-mute"),
+ deafen: $("voice-deafen"),
+ settingsToggle: $("voice-settings-toggle"),
+ settings: $("voice-settings"),
+ mode: $("voice-mode"),
+ ptt: $("voice-ptt"),
+ leave: $("voice-leave"),
+ sensitivity: $("voice-sensitivity"),
+ volume: $("voice-volume"),
+ container: $("ajax-container"),
+ };
+
+ const STORE = {
+ nickname: "ts6voice.nickname",
+ password: "ts6voice.password",
+ identity: "ts6voice.identity",
+ sensitivity: "ts6voice.sensitivity",
+ volume: "ts6voice.volume",
+ mode: "ts6voice.mode",
+ userVolumes: "ts6voice.userVolumes",
+ };
+ const store = {
+ get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
+ set: (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } },
+ };
+
+ const KIND_PCM = 0xf0;
+ const PTT_KEY = "KeyV";
+ const VAD_HANG_MS = 350;
+ const TALK_TIMEOUT_MS = 300;
+ const START_LATENCY_S = 0.06;
+ const MAX_AHEAD_S = 0.25;
+ const MAX_SEND_BUFFER = 16 * 1024;
+ const MAX_CHAT_HISTORY = 100;
+
+ let session = null; // active session, see startJoin()
+ let pendingJoin = null; // undefined = save only; null = join default channel; {cid,name} = join specific channel
+
+ // ---- per-user volume storage -----------------------------------------------
+
+ let savedUserVolumes = {};
+ try {
+ const raw = store.get(STORE.userVolumes);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === "object") savedUserVolumes = parsed;
+ }
+ } catch {
+ savedUserVolumes = {};
+ }
+
+ function clampUserVolume(val) {
+ const num = Number(val);
+ if (!Number.isFinite(num)) return 100;
+ return Math.max(0, Math.min(200, Math.round(num)));
+ }
+
+ function getUserVolumePercent(clientId, nickname) {
+ if (session && session.userVolumes.has(clientId)) {
+ return session.userVolumes.get(clientId);
+ }
+ if (nickname && Object.prototype.hasOwnProperty.call(savedUserVolumes, nickname)) {
+ return clampUserVolume(savedUserVolumes[nickname]);
+ }
+ return 100;
+ }
+
+ function setUserVolumePercent(clientId, nickname, percent) {
+ const clamped = clampUserVolume(percent);
+ if (session) {
+ session.userVolumes.set(clientId, clamped);
+ if (session.playback) session.playback.setUserVolume(clientId, clamped / 100);
+ }
+ if (nickname) {
+ savedUserVolumes[nickname] = clamped;
+ store.set(STORE.userVolumes, JSON.stringify(savedUserVolumes));
+ }
+ return clamped;
+ }
+
+ // ---- messages & topbar summary ---------------------------------------------
+
+ function setText(node, text, kind = "info") {
+ if (!node) return;
+ node.textContent = text || "";
+ node.dataset.kind = kind;
+ node.hidden = !text;
+ }
+
+ /** Route a message to wherever the user is looking right now. */
+ function say(text, kind = "info") {
+ if (session && session.ready) setText(ui.barMessage, text, kind);
+ else if (ui.dialog.open) setText(ui.dialogMessage, text, kind);
+ else setText(ui.message, text, kind);
+ }
+
+ function updateTopbarSummary() {
+ const name = (ui.nickname.value || "").trim();
+ const pass = ui.password.value || "";
+ if (session && session.ready) {
+ ui.topbarIdentity.textContent = `[ ${session.nickname} // connected ]`;
+ ui.topbarIdentity.dataset.ready = "1";
+ } else if (name.length >= 3 && pass) {
+ ui.topbarIdentity.textContent = `[ ${name} // password saved ]`;
+ ui.topbarIdentity.dataset.ready = "1";
+ } else if (name.length >= 3) {
+ ui.topbarIdentity.textContent = `[ ${name} // no password ]`;
+ ui.topbarIdentity.dataset.ready = "";
+ } else {
+ ui.topbarIdentity.textContent = "[ not logged in ]";
+ ui.topbarIdentity.dataset.ready = "";
+ }
+ }
+
+ // ---- browser connect modal --------------------------------------------------
+
+ function openLoginModal(joinIntent = null, errorMsg = "") {
+ pendingJoin = joinIntent;
+ if (joinIntent === undefined) {
+ ui.dialogTarget.textContent = "credentials";
+ ui.submit.textContent = "save";
+ } else if (joinIntent && joinIntent.name) {
+ ui.dialogTarget.textContent = joinIntent.name;
+ ui.submit.textContent = "connect";
+ } else {
+ ui.dialogTarget.textContent = "default channel";
+ ui.submit.textContent = "connect";
+ }
+ setText(ui.dialogMessage, errorMsg, errorMsg ? "error" : "info");
+ if (!ui.dialog.open) ui.dialog.showModal();
+ (ui.nickname.value.trim().length >= 3 ? ui.password : ui.nickname).focus();
+ }
+
+ function closeLoginModal() {
+ pendingJoin = null;
+ if (ui.dialog.open) ui.dialog.close();
+ }
+
+ ui.loginBtn.addEventListener("click", () => {
+ if (session && session.ready) openLoginModal(undefined);
+ else openLoginModal(null);
+ });
+ ui.cancel.addEventListener("click", () => {
+ if (session && !session.ready) teardown("", "info");
+ closeLoginModal();
+ });
+ ui.dialog.addEventListener("cancel", (e) => {
+ if (session && !session.ready) {
+ e.preventDefault();
+ teardown("", "info");
+ closeLoginModal();
+ }
+ });
+ ui.dialog.addEventListener("click", (e) => {
+ if (e.target === ui.dialog && (!session || session.ready)) closeLoginModal();
+ });
+
+ // ---- preferences --------------------------------------------------------------
+
+ ui.nickname.value = store.get(STORE.nickname) || "";
+ ui.password.value = store.get(STORE.password) || "";
+ if (store.get(STORE.sensitivity) !== null) ui.sensitivity.value = store.get(STORE.sensitivity);
+ if (store.get(STORE.volume) !== null) ui.volume.value = store.get(STORE.volume);
+ let mode = store.get(STORE.mode) === "ptt" ? "ptt" : "vad";
+ updateTopbarSummary();
+
+ ui.nickname.addEventListener("input", () => {
+ store.set(STORE.nickname, ui.nickname.value.trim());
+ updateTopbarSummary();
+ setText(ui.dialogMessage, "");
+ });
+ ui.password.addEventListener("input", () => {
+ store.set(STORE.password, ui.password.value);
+ updateTopbarSummary();
+ setText(ui.dialogMessage, "");
+ });
+
+ function levelFromRms(rms) {
+ const db = 20 * Math.log10(Math.max(rms, 1e-6));
+ return Math.min(1, Math.max(0, (db + 60) / 60));
+ }
+ const threshold = () => 1 - Number(ui.sensitivity.value) / 100;
+ function updateThresholdMark() { ui.threshold.style.left = `${threshold() * 100}%`; }
+ function applyModeUi() {
+ ui.mode.textContent = mode === "ptt" ? "push to talk" : "voice activity";
+ ui.ptt.hidden = mode !== "ptt";
+ ui.threshold.hidden = mode === "ptt";
+ }
+ updateThresholdMark();
+ applyModeUi();
+
+ // ---- live channel list & chat (channel + individual DMs) --------------------
+
+ function sortChannels(channels, pid = "0", depth = 0) {
+ const byOrder = new Map();
+ for (const c of channels) if (c.parentId === pid) byOrder.set(c.order, c);
+ const ordered = [];
+ let current = "0";
+ let guard = 0;
+ while (byOrder.has(current) && guard++ <= channels.length && depth < 32) {
+ const channel = byOrder.get(current);
+ ordered.push(channel);
+ ordered.push(...sortChannels(channels, channel.id, depth + 1));
+ current = channel.id;
+ }
+ return ordered;
+ }
+
+ function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+ }
+
+ function safeUrl(path) {
+ return encodeURI(String(path || "")).replace(/["'()\\]/g, (c) => "%" + c.charCodeAt(0).toString(16));
+ }
+
+ function statItem(label, value) {
+ const item = el("div", "stat-item");
+ item.append(el("span", "stat-label", label), el("span", "stat-value", String(value)));
+ return item;
+ }
+
+ const displayName = (name) => name.replace("[Cspacer]", "");
+
+ function formatTime(ts) {
+ try {
+ const d = new Date(ts || Date.now());
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+ } catch {
+ return "";
+ }
+ }
+
+ function findClientName(clientId, fallback = "") {
+ if (session && session.snapshot) {
+ const found = session.snapshot.clients.find((c) => c.id === clientId);
+ if (found && found.nickname) return found.nickname;
+ }
+ const existing = session && session.dmByUser.get(clientId);
+ if (existing && existing.peerName) return existing.peerName;
+ return fallback || `user #${clientId}`;
+ }
+
+ function ensureDmEntry(peerId, peerName) {
+ let dm = session.dmByUser.get(peerId);
+ const resolvedName = findClientName(peerId, peerName);
+ if (!dm) {
+ dm = { peerId, peerName: resolvedName, messages: [], unread: 0 };
+ session.dmByUser.set(peerId, dm);
+ } else if (resolvedName && dm.peerName !== resolvedName) {
+ dm.peerName = resolvedName;
+ }
+ return dm;
+ }
+
+ function openDmConversation(peerId, peerName) {
+ if (!session || !session.ready || !peerId || peerId === session.selfId) return;
+ const dm = ensureDmEntry(peerId, peerName);
+ dm.unread = 0;
+ session.activeChatTab = { type: "dm", peerId, peerName: dm.peerName };
+ render();
+ const input = ui.container.querySelector(".channel-chat-input");
+ if (input) {
+ input.focus();
+ input.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ }
+
+ function buildChatMsgRow(m, selfId) {
+ const isSelf = m.invokerId === selfId;
+ const row = el(
+ "div",
+ "channel-chat-msg"
+ + (isSelf ? " is-self" : "")
+ + (m.scope === "private" ? " is-private" : m.scope === "server" ? " is-server" : "")
+ );
+ row.append(el("span", "channel-chat-time", formatTime(m.timestamp)));
+ const authorLabel = m.scope === "server" ? `[server] ${m.invokerName}` : m.invokerName;
+ row.append(el("span", "channel-chat-author", `${authorLabel}:`));
+ row.append(el("span", "channel-chat-text", m.message));
+ return row;
+ }
+
+ function buildChannelChat(channelId, channelTitle) {
+ const wrap = el("section", "channel-chat");
+ wrap.dataset.chatCid = channelId;
+
+ const activeTab = session ? session.activeChatTab : { type: "channel" };
+ const isDmTab = activeTab && activeTab.type === "dm";
+ const activeDm = isDmTab && session ? session.dmByUser.get(activeTab.peerId) : null;
+ if (isDmTab && !activeDm && session) {
+ session.activeChatTab = { type: "channel" };
+ }
+ const showingDm = Boolean(isDmTab && activeDm);
+
+ const head = el("header", "channel-chat-header");
+ const tabs = el("div", "channel-chat-tabs");
+
+ const chTab = el(
+ "button",
+ "channel-chat-tab" + (!showingDm ? " is-active" : ""),
+ `# ${channelTitle}`
+ );
+ chTab.type = "button";
+ chTab.dataset.chatTab = "channel";
+ tabs.append(chTab);
+
+ if (session) {
+ for (const dm of session.dmByUser.values()) {
+ const isCurrentDm = showingDm && activeDm.peerId === dm.peerId;
+ const dmBtn = el(
+ "button",
+ "channel-chat-tab is-dm" + (isCurrentDm ? " is-active" : "") + (dm.unread > 0 ? " has-unread" : ""),
+ dm.unread > 0 ? `@ ${dm.peerName} (${dm.unread})` : `@ ${dm.peerName}`
+ );
+ dmBtn.type = "button";
+ dmBtn.dataset.chatTab = "dm";
+ dmBtn.dataset.dmPeer = String(dm.peerId);
+ tabs.append(dmBtn);
+ }
+ }
+ head.append(tabs);
+
+ const log = el("div", "channel-chat-messages");
+ log.id = "voice-chat-log";
+ if (showingDm) {
+ const msgs = activeDm.messages;
+ if (msgs.length === 0) {
+ log.append(el("div", "channel-chat-empty", `No private messages with ${activeDm.peerName} yet.`));
+ } else {
+ for (const m of msgs) log.append(buildChatMsgRow(m, session.selfId));
+ }
+ } else {
+ const msgs = (session && session.chatByChannel.get(channelId)) || [];
+ if (msgs.length === 0) {
+ log.append(el("div", "channel-chat-empty", "No messages in this channel yet."));
+ } else {
+ for (const m of msgs) log.append(buildChatMsgRow(m, session.selfId));
+ }
+ }
+
+ const draftKey = showingDm ? `dm:${activeDm.peerId}` : `channel:${channelId}`;
+ const form = el("form", "channel-chat-form");
+ form.dataset.chatScope = showingDm ? "private" : "channel";
+ form.dataset.chatCid = channelId;
+ if (showingDm) form.dataset.chatTargetId = String(activeDm.peerId);
+
+ const input = el("input", "channel-chat-input");
+ input.type = "text";
+ input.name = "message";
+ input.maxLength = 1024;
+ input.autocomplete = "off";
+ input.placeholder = showingDm ? `message @${activeDm.peerName}...` : `message #${channelTitle}...`;
+ input.value = (session && session.chatDrafts.get(draftKey)) || "";
+ input.addEventListener("input", () => {
+ if (session) session.chatDrafts.set(draftKey, input.value);
+ });
+ const sendBtn = el("button", "channel-chat-send", "send");
+ sendBtn.type = "submit";
+ form.append(input, sendBtn);
+
+ wrap.append(head, log, form);
+ queueMicrotask(() => { log.scrollTop = log.scrollHeight; });
+ return wrap;
+ }
+
+ function appendChatMessage(msg) {
+ if (!session || !session.snapshot) return;
+
+ if (msg.scope === "private") {
+ const isFromSelf = msg.invokerId === session.selfId;
+ const peerId = isFromSelf ? Number(msg.targetId) : Number(msg.invokerId);
+ if (!peerId) return;
+ const peerName = isFromSelf ? findClientName(peerId) : (msg.invokerName || findClientName(peerId));
+ const dm = ensureDmEntry(peerId, peerName);
+ dm.messages.push(msg);
+ if (dm.messages.length > MAX_CHAT_HISTORY) dm.messages.shift();
+
+ const viewingThisDm = session.activeChatTab
+ && session.activeChatTab.type === "dm"
+ && session.activeChatTab.peerId === peerId;
+
+ if (viewingThisDm) {
+ dm.unread = 0;
+ const log = document.getElementById("voice-chat-log");
+ if (log) {
+ const empty = log.querySelector(".channel-chat-empty");
+ if (empty) empty.remove();
+ log.append(buildChatMsgRow(msg, session.selfId));
+ log.scrollTop = log.scrollHeight;
+ return;
+ }
+ } else if (!isFromSelf) {
+ dm.unread += 1;
+ }
+ render();
+ return;
+ }
+
+ const cid = msg.scope === "channel" && msg.targetId
+ ? String(msg.targetId)
+ : String(session.snapshot.self.channelId);
+ let list = session.chatByChannel.get(cid);
+ if (!list) {
+ list = [];
+ session.chatByChannel.set(cid, list);
+ }
+ list.push(msg);
+ if (list.length > MAX_CHAT_HISTORY) list.shift();
+
+ const viewingChannel = !session.activeChatTab || session.activeChatTab.type === "channel";
+ if (cid !== String(session.snapshot.self.channelId) || !viewingChannel) return;
+ const log = document.getElementById("voice-chat-log");
+ if (!log) return;
+ const empty = log.querySelector(".channel-chat-empty");
+ if (empty) empty.remove();
+ log.append(buildChatMsgRow(msg, session.selfId));
+ log.scrollTop = log.scrollHeight;
+ }
+
+ function render() {
+ if (!session || !session.snapshot) return;
+ const activeEl = document.activeElement;
+ const restoreChatFocus = Boolean(activeEl && activeEl.classList && activeEl.classList.contains("channel-chat-input"));
+ const selectionStart = restoreChatFocus ? activeEl.selectionStart : null;
+ const selectionEnd = restoreChatFocus ? activeEl.selectionEnd : null;
+ const activeVolClid = activeEl && activeEl.classList && activeEl.classList.contains("client-volume-slider")
+ ? activeEl.dataset.volClid
+ : null;
+
+ const snap = session.snapshot;
+ const channels = sortChannels(snap.channels);
+ const realClients = snap.clients.filter((c) => c.type !== 1);
+ const byChannel = new Map();
+ for (const c of realClients) {
+ if (!byChannel.has(c.channelId)) byChannel.set(c.channelId, []);
+ byChannel.get(c.channelId).push(c);
+ if (c.id !== snap.self.id && !session.userVolumes.has(c.id)) {
+ const vol = getUserVolumePercent(c.id, c.nickname);
+ session.userVolumes.set(c.id, vol);
+ if (session.playback) session.playback.setUserVolume(c.id, vol / 100);
+ }
+ if (session.dmByUser.has(c.id)) {
+ session.dmByUser.get(c.id).peerName = c.nickname;
+ }
+ }
+
+ // Stats mirror build_context() in app.py.
+ let afk = 0;
+ let afkChannel = null;
+ for (const ch of snap.channels) if (ch.name.includes("AFK")) afkChannel = ch;
+ if (afkChannel) afk = Math.max(0, snap.clients.filter((c) => c.channelId === afkChannel.id).length - 1);
+ afk += realClients.filter((c) => c.away).length;
+
+ const stats = el("div", "server-stats");
+ stats.append(statItem("Users Connected", realClients.length), statItem("AFK Users", afk));
+
+ const list = el("div", "channel-list");
+ for (const ch of channels) {
+ if (ch.name.includes("[Cspacer]") && !ch.name.includes("AFK")) continue; // spacer
+ const name = displayName(ch.name);
+ const members = byChannel.get(ch.id) || [];
+ const isCurrent = ch.id === snap.self.channelId;
+
+ const article = el("article", "channel" + (isCurrent ? " is-current" : ""));
+ article.dataset.cid = ch.id;
+
+ const header = el("header", "channel-header");
+ const bg = (cfg.channelBanners || {})[name] || (cfg.genericImages || {}).background || "";
+ if (bg) {
+ header.style.backgroundImage =
+ `linear-gradient(90deg, rgba(9, 18, 32, 0.96) 0%, rgba(9, 18, 32, 0.78) 45%, rgba(9, 18, 32, 0.94) 100%), url("${safeUrl(bg)}")`;
+ }
+ const title = el("div", "channel-title-group");
+ const icon = (cfg.channelIcons || {})[name] || (cfg.genericImages || {}).icon || "";
+ if (icon) {
+ const img = el("img", "channel-icon");
+ img.src = safeUrl(icon);
+ img.alt = "";
+ img.setAttribute("aria-hidden", "true");
+ title.append(img);
+ }
+ title.append(el("h2", null, name));
+ if (ch.hasPassword) title.append(el("span", "channel-lock", "pw"));
+
+ const actions = el("div", "channel-actions");
+ actions.append(el("span", "channel-meta", String(members.length)));
+ if (isCurrent) {
+ actions.append(el("span", "channel-here", "joined"));
+ } else {
+ const joinBtn = el("button", "channel-join", "join");
+ joinBtn.type = "button";
+ joinBtn.dataset.cid = ch.id;
+ joinBtn.dataset.name = name;
+ joinBtn.dataset.locked = ch.hasPassword ? "1" : "";
+ joinBtn.setAttribute("aria-label", `Move to ${name}`);
+ actions.append(joinBtn);
+ }
+ header.append(title, actions);
+
+ const ul = el("ul", "client-list");
+ for (const c of members) {
+ const li = el("li", "client");
+ li.dataset.clid = String(c.id);
+ const isSelf = c.id === snap.self.id;
+ if (isSelf) li.classList.add("is-self");
+ if (session.talking.has(c.id)) li.classList.add("is-talking");
+
+ let roleIcon = "";
+ for (const g of c.serverGroups) if (!roleIcon && cfg.roleIcons && cfg.roleIcons[g]) roleIcon = cfg.roleIcons[g];
+ if (roleIcon) {
+ const img = el("img", "role-icon");
+ img.src = safeUrl(roleIcon);
+ img.alt = "Role";
+ li.append(img);
+ } else {
+ li.append(el("span", "client-no-role-icon"));
+ }
+ li.append(el("span", "client-name", c.nickname));
+ if (isSelf) li.append(el("span", "client-you", "(you)"));
+ if (c.away) li.append(el("span", "client-afk", c.awayMessage ? `(AFK: ${c.awayMessage})` : "(AFK)"));
+ if (c.outputMuted) li.append(el("span", "client-muted", "(deafened)"));
+ else if (c.inputMuted) li.append(el("span", "client-muted", "(muted)"));
+
+ if (!isSelf) {
+ const controls = el("div", "client-controls");
+
+ const volPct = getUserVolumePercent(c.id, c.nickname);
+ const volWrap = el("label", "client-volume");
+ volWrap.title = `Volume for ${c.nickname}`;
+ const slider = el("input", "client-volume-slider");
+ slider.type = "range";
+ slider.min = "0";
+ slider.max = "200";
+ slider.step = "5";
+ slider.value = String(volPct);
+ slider.dataset.volClid = String(c.id);
+ slider.dataset.volName = c.nickname;
+ slider.setAttribute("aria-label", `Volume for ${c.nickname}`);
+ const volVal = el("span", "client-volume-value", `${volPct}%`);
+ volWrap.append(slider, volVal);
+
+ const dmEntry = session.dmByUser.get(c.id);
+ const unread = dmEntry ? dmEntry.unread : 0;
+ const isDmOpen = session.activeChatTab
+ && session.activeChatTab.type === "dm"
+ && session.activeChatTab.peerId === c.id;
+ const msgBtn = el(
+ "button",
+ "client-msg-btn" + (isDmOpen ? " is-active" : "") + (unread > 0 ? " has-unread" : ""),
+ unread > 0 ? `msg (${unread})` : "msg"
+ );
+ msgBtn.type = "button";
+ msgBtn.dataset.msgClid = String(c.id);
+ msgBtn.dataset.msgName = c.nickname;
+ msgBtn.title = `Message ${c.nickname}`;
+
+ controls.append(volWrap, msgBtn);
+ li.append(controls);
+ }
+ ul.append(li);
+ }
+ article.append(header, ul);
+ if (isCurrent) {
+ article.append(buildChannelChat(ch.id, name));
+ }
+ list.append(article);
+ }
+
+ ui.container.replaceChildren(stats, list);
+
+ if (restoreChatFocus) {
+ const nextInput = ui.container.querySelector(".channel-chat-input");
+ if (nextInput) {
+ nextInput.focus();
+ try {
+ if (selectionStart !== null && selectionEnd !== null) nextInput.setSelectionRange(selectionStart, selectionEnd);
+ } catch { /* ignore */ }
+ }
+ } else if (activeVolClid) {
+ const nextSlider = ui.container.querySelector(`.client-volume-slider[data-vol-clid="${activeVolClid}"]`);
+ if (nextSlider) nextSlider.focus();
+ }
+
+ const current = snap.channels.find((c) => c.id === snap.self.channelId);
+ ui.channel.textContent = current ? displayName(current.name) : "-";
+ const me = snap.clients.find((c) => c.id === snap.self.id);
+ ui.self.textContent = me ? me.nickname : session.nickname;
+ updateTopbarSummary();
+ }
+
+ // ---- talking indicators -------------------------------------------------------
+
+ function setTalking(clientId, talking) {
+ if (!session) return;
+ const timers = session.talkTimers;
+ clearTimeout(timers.get(clientId));
+ if (talking) {
+ timers.set(clientId, setTimeout(() => setTalking(clientId, false), TALK_TIMEOUT_MS));
+ if (session.talking.has(clientId)) return;
+ session.talking.add(clientId);
+ } else {
+ timers.delete(clientId);
+ if (!session.talking.delete(clientId)) return;
+ }
+ for (const li of ui.container.querySelectorAll(`.client[data-clid="${clientId}"]`)) {
+ li.classList.toggle("is-talking", talking);
+ }
+ }
+
+ // ---- playback (with per-user GainNode) ----------------------------------------
+
+ function createPlayback(ctx, output, getInitialGain) {
+ const speakers = new Map();
+
+ function stream(clientId) {
+ let s = speakers.get(clientId);
+ if (!s) {
+ const gain = ctx.createGain();
+ gain.gain.value = getInitialGain ? getInitialGain(clientId) : 1;
+ gain.connect(output);
+ s = { playTime: 0, timestamp: 0, decoder: null, gain };
+ speakers.set(clientId, s);
+ }
+ return s;
+ }
+
+ function schedule(s, buffer) {
+ const now = ctx.currentTime;
+ if (s.playTime < now) s.playTime = now + START_LATENCY_S;
+ if (s.playTime > now + MAX_AHEAD_S) return;
+ const src = ctx.createBufferSource();
+ src.buffer = buffer;
+ src.connect(s.gain);
+ src.onended = () => src.disconnect();
+ src.start(s.playTime);
+ s.playTime += buffer.duration;
+ }
+
+ function playPcm(clientId, bytes) {
+ const samples = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
+ const buffer = ctx.createBuffer(1, samples.length, 48000);
+ const data = buffer.getChannelData(0);
+ for (let i = 0; i < samples.length; i++) data[i] = samples[i] / 32768;
+ schedule(stream(clientId), buffer);
+ }
+
+ function playOpus(clientId, bytes) {
+ const s = stream(clientId);
+ if (!s.decoder || s.decoder.state === "closed") {
+ s.decoder = new AudioDecoder({
+ output: (audio) => {
+ try {
+ const n = audio.numberOfFrames;
+ const buffer = ctx.createBuffer(1, n, audio.sampleRate);
+ const tmp = new Float32Array(n);
+ audio.copyTo(tmp, { planeIndex: 0, format: "f32-planar" });
+ buffer.copyToChannel(tmp, 0);
+ schedule(s, buffer);
+ } catch { /* drop frame */ } finally {
+ audio.close();
+ }
+ },
+ error: () => { s.decoder = null; },
+ });
+ s.decoder.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 1 });
+ s.timestamp = 0;
+ }
+ if (s.decoder.decodeQueueSize > 5) return;
+ s.decoder.decode(new EncodedAudioChunk({ type: "key", timestamp: s.timestamp, duration: 20000, data: bytes }));
+ s.timestamp += 20000;
+ }
+
+ return {
+ play(kind, clientId, bytes) {
+ try {
+ if (kind === KIND_PCM) playPcm(clientId, bytes);
+ else playOpus(clientId, bytes);
+ } catch { /* ignore broken frames */ }
+ },
+ setUserVolume(clientId, gainValue) {
+ const s = speakers.get(clientId);
+ if (s && s.gain) s.gain.gain.value = gainValue;
+ },
+ close() {
+ for (const s of speakers.values()) {
+ try { s.decoder && s.decoder.close(); } catch { /* closed */ }
+ try { s.gain && s.gain.disconnect(); } catch { /* disconnected */ }
+ }
+ speakers.clear();
+ },
+ };
+ }
+
+ async function browserDecodesOpus() {
+ if (typeof AudioDecoder === "undefined" || typeof EncodedAudioChunk === "undefined") return false;
+ try {
+ const res = await AudioDecoder.isConfigSupported({ codec: "opus", sampleRate: 48000, numberOfChannels: 1 });
+ return Boolean(res.supported);
+ } catch {
+ return false;
+ }
+ }
+
+ // ---- microphone ---------------------------------------------------------------
+
+ function gateOpen(s, rms) {
+ if (s.muted) return false;
+ if (mode === "ptt") return s.pttDown;
+ const now = performance.now();
+ if (levelFromRms(rms) >= threshold()) s.vadUntil = now + VAD_HANG_MS;
+ return now < s.vadUntil;
+ }
+
+ function onMicFrame(s, { samples, rms }) {
+ if (session !== s) return;
+ ui.meter.style.width = `${levelFromRms(rms) * 100}%`;
+ const open = s.ready && gateOpen(s, rms);
+ ui.meter.classList.toggle("is-open", open);
+ if (!open) {
+ if (s.wasTalking) { s.wasTalking = false; setTalking(s.selfId, false); }
+ return;
+ }
+ if (s.ws.readyState !== WebSocket.OPEN || s.ws.bufferedAmount > MAX_SEND_BUFFER) return;
+ const pcm = new Int16Array(samples.length);
+ for (let i = 0; i < samples.length; i++) {
+ const v = Math.max(-1, Math.min(1, samples[i]));
+ pcm[i] = v < 0 ? v * 0x8000 : v * 0x7fff;
+ }
+ s.ws.send(pcm.buffer);
+ s.wasTalking = true;
+ setTalking(s.selfId, true);
+ }
+
+ // ---- session lifecycle --------------------------------------------------------
+
+ function setPhase(phase) {
+ document.body.classList.toggle("voice-connecting", phase === "connecting");
+ document.body.classList.toggle("voice-live", phase === "live");
+ ui.bar.hidden = phase !== "live";
+ ui.submit.disabled = phase === "connecting";
+ }
+
+ async function startJoin(targetChannel = null) {
+ if (session) return;
+ const nickname = ui.nickname.value.trim();
+ const password = ui.password.value;
+ if (nickname.length < 3 || !password) {
+ openLoginModal(targetChannel);
+ return;
+ }
+ if (!window.isSecureContext || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ return say("Browser voice needs HTTPS (or localhost) for microphone access.", "error");
+ }
+
+ store.set(STORE.nickname, nickname);
+ store.set(STORE.password, password);
+ updateTopbarSummary();
+ setText(ui.notice, "");
+
+ const s = {
+ nickname, target: targetChannel, ws: null, ctx: null, stream: null, worklet: null, playback: null, output: null,
+ ready: false, muted: false, deafened: false, pttDown: false, vadUntil: 0, wasTalking: false,
+ selfId: 0, snapshot: null, talking: new Set(), talkTimers: new Map(),
+ userVolumes: new Map(),
+ chatByChannel: new Map(), dmByUser: new Map(), activeChatTab: { type: "channel" }, chatDrafts: new Map(),
+ closed: false,
+ };
+ session = s;
+ setPhase("connecting");
+ say(`Requesting microphone to join ${targetChannel && targetChannel.name ? targetChannel.name : "voice"}…`);
+
+ try {
+ // The AudioContext must be created inside the click handler's turn.
+ s.ctx = new AudioContext({ latencyHint: "interactive" });
+ const resume = s.ctx.resume();
+ s.stream = await navigator.mediaDevices.getUserMedia({
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 },
+ });
+ await resume;
+ await s.ctx.audioWorklet.addModule("/static/mic-worklet.js");
+ if (s.closed) return;
+
+ const source = s.ctx.createMediaStreamSource(s.stream);
+ s.worklet = new AudioWorkletNode(s.ctx, "ts6-mic-capture", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
+ const sink = s.ctx.createGain();
+ sink.gain.value = 0;
+ source.connect(s.worklet).connect(sink).connect(s.ctx.destination);
+ s.worklet.port.onmessage = (e) => onMicFrame(s, e.data);
+
+ s.output = s.ctx.createGain();
+ s.output.gain.value = Number(ui.volume.value) / 100;
+ s.output.connect(s.ctx.destination);
+ s.playback = createPlayback(s.ctx, s.output, (clientId) => {
+ const nick = findClientName(clientId, "");
+ return getUserVolumePercent(clientId, nick) / 100;
+ });
+ } catch (error) {
+ if (s.closed) return;
+ const denied = error && (error.name === "NotAllowedError" || error.name === "SecurityError");
+ teardown(denied ? "Microphone access was denied." : `Could not start audio: ${error && error.message ? error.message : error}`, "error");
+ return;
+ }
+
+ const serverDecode = !(await browserDecodesOpus());
+ if (s.closed) return;
+ say("Connecting to TeamSpeak…");
+
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const ws = new WebSocket(`${proto}//${location.host}${cfg.wsPath || "/ws/voice"}`);
+ ws.binaryType = "arraybuffer";
+ s.ws = ws;
+ let fatalMessage = "";
+ let fatalCode = "";
+
+ ws.onopen = () => {
+ ws.send(JSON.stringify({
+ type: "join",
+ nickname,
+ password,
+ channelId: s.target && s.target.cid ? s.target.cid : undefined,
+ identity: store.get(STORE.identity) || undefined,
+ serverDecode,
+ }));
+ };
+
+ ws.onmessage = (e) => {
+ if (session !== s) return;
+ if (typeof e.data !== "string") {
+ const bytes = new Uint8Array(e.data);
+ if (bytes.length < 3) return;
+ const kind = bytes[0];
+ const clientId = (bytes[1] << 8) | bytes[2];
+ const payload = bytes.subarray(3);
+ if (payload.length === 0) return setTalking(clientId, false);
+ setTalking(clientId, true);
+ s.playback.play(kind, clientId, payload);
+ return;
+ }
+ let msg;
+ try { msg = JSON.parse(e.data); } catch { return; }
+ switch (msg.type) {
+ case "joined":
+ s.ready = true;
+ s.selfId = msg.self.id;
+ s.snapshot = msg;
+ if (msg.identity) store.set(STORE.identity, msg.identity);
+ store.set(STORE.nickname, nickname);
+ store.set(STORE.password, password);
+ updateTopbarSummary();
+ closeLoginModal();
+ setText(ui.message, "");
+ setText(ui.dialogMessage, "");
+ setPhase("live");
+ setText(ui.barMessage, serverDecode ? "Compatibility audio mode (your browser lacks WebCodecs)." : "");
+ render();
+ break;
+ case "state":
+ s.snapshot = msg;
+ s.selfId = msg.self.id;
+ render();
+ break;
+ case "chatMessage":
+ appendChatMessage(msg);
+ break;
+ case "error":
+ if (msg.fatal) {
+ fatalMessage = msg.message;
+ fatalCode = msg.code || "";
+ break;
+ }
+ if (msg.code === "CHANNEL_PASSWORD" && msg.channelId) {
+ promptChannelPassword(msg.channelId);
+ break;
+ }
+ say(msg.message, "error");
+ break;
+ case "disconnected":
+ fatalMessage = msg.reason || "Disconnected.";
+ break;
+ }
+ };
+
+ ws.onclose = (e) => {
+ if (session !== s) return;
+ let reason = fatalMessage;
+ if (!reason) {
+ if (s.ready) reason = e.code === 1006 ? "Connection to the voice gateway was lost." : "Disconnected.";
+ else reason = "Voice gateway unreachable, full, or this origin is not allowed.";
+ }
+ const targetBeforeClose = s.target;
+ teardown(reason, s.ready && !fatalMessage ? "info" : "error");
+ if (fatalCode === "BAD_PASSWORD" || fatalCode === "NICKNAME_INVALID" || fatalCode === "NICKNAME_IN_USE") {
+ openLoginModal(targetBeforeClose, reason);
+ }
+ };
+ }
+
+ function teardown(message, kind = "info") {
+ const s = session;
+ if (!s) return;
+ s.closed = true;
+ const wasReady = s.ready;
+ session = null;
+ for (const t of s.talkTimers.values()) clearTimeout(t);
+ if (s.ws) {
+ s.ws.onclose = null;
+ try {
+ if (s.ws.readyState === WebSocket.OPEN) s.ws.send(JSON.stringify({ type: "leave" }));
+ s.ws.close(1000);
+ } catch { /* closed */ }
+ }
+ if (s.stream) for (const track of s.stream.getTracks()) track.stop();
+ if (s.worklet) s.worklet.port.onmessage = null;
+ if (s.playback) s.playback.close();
+ if (s.ctx) s.ctx.close().catch(() => undefined);
+
+ setPhase("idle");
+ updateTopbarSummary();
+ ui.meter.style.width = "0%";
+ ui.settings.hidden = true;
+ ui.settingsToggle.setAttribute("aria-expanded", "false");
+ setMuteUi(false);
+ setDeafenUi(false);
+ setText(ui.barMessage, "");
+ if (!wasReady && ui.dialog.open) setText(ui.dialogMessage, message, kind);
+ else if (!wasReady) setText(ui.message, message, kind);
+ else setText(ui.notice, message, kind);
+ if (wasReady && typeof window.refreshDashboard === "function") window.refreshDashboard();
+ }
+
+ // ---- channel moves, per-user volume & chat ------------------------------------
+
+ function sendJson(message) {
+ if (session && session.ready && session.ws.readyState === WebSocket.OPEN) session.ws.send(JSON.stringify(message));
+ }
+
+ function channelName(cid) {
+ const ch = session && session.snapshot && session.snapshot.channels.find((c) => c.id === cid);
+ return ch ? displayName(ch.name) : "this channel";
+ }
+
+ function promptChannelPassword(cid) {
+ const password = window.prompt(`Password for ${channelName(cid)}`);
+ if (password === null) return;
+ sendJson({ type: "move", channelId: cid, password });
+ }
+
+ ui.container.addEventListener("input", (e) => {
+ const slider = e.target.closest(".client-volume-slider");
+ if (!slider) return;
+ const clid = Number(slider.dataset.volClid);
+ const name = slider.dataset.volName || "";
+ const pct = setUserVolumePercent(clid, name, slider.value);
+ const label = slider.parentElement && slider.parentElement.querySelector(".client-volume-value");
+ if (label) label.textContent = `${pct}%`;
+ });
+
+ ui.container.addEventListener("click", (e) => {
+ const msgBtn = e.target.closest(".client-msg-btn");
+ if (msgBtn) {
+ const clid = Number(msgBtn.dataset.msgClid);
+ const name = msgBtn.dataset.msgName || "";
+ if (clid) openDmConversation(clid, name);
+ return;
+ }
+
+ const tabBtn = e.target.closest(".channel-chat-tab");
+ if (tabBtn && session && session.ready) {
+ if (tabBtn.dataset.chatTab === "dm") {
+ const peerId = Number(tabBtn.dataset.dmPeer);
+ if (peerId) openDmConversation(peerId);
+ } else {
+ session.activeChatTab = { type: "channel" };
+ render();
+ const input = ui.container.querySelector(".channel-chat-input");
+ if (input) input.focus();
+ }
+ return;
+ }
+
+ const button = e.target.closest(".channel-join");
+ if (!button) return;
+ const cid = button.dataset.cid;
+ if (!session) return void startJoin({ cid, name: button.dataset.name || "" });
+ if (!session.ready) return;
+ setText(ui.barMessage, "");
+ if (button.dataset.locked) return promptChannelPassword(cid);
+ sendJson({ type: "move", channelId: cid });
+ });
+
+ ui.container.addEventListener("submit", (e) => {
+ const form = e.target.closest(".channel-chat-form");
+ if (!form || !session || !session.ready) return;
+ e.preventDefault();
+ const input = form.querySelector(".channel-chat-input");
+ if (!input) return;
+ const text = input.value.trim();
+ if (!text) return;
+ const scope = form.dataset.chatScope || "channel";
+ if (scope === "private") {
+ const targetId = Number(form.dataset.chatTargetId);
+ if (!targetId) return;
+ input.value = "";
+ session.chatDrafts.set(`dm:${targetId}`, "");
+ sendJson({ type: "chat", scope: "private", targetId, message: text });
+ } else {
+ const cid = form.dataset.chatCid;
+ input.value = "";
+ session.chatDrafts.set(`channel:${cid}`, "");
+ sendJson({ type: "chat", scope: "channel", message: text });
+ }
+ });
+
+ // ---- controls -----------------------------------------------------------------
+
+ function setMuteUi(on) {
+ ui.mute.setAttribute("aria-pressed", String(on));
+ ui.mute.textContent = on ? "unmute" : "mute";
+ }
+
+ function setDeafenUi(on) {
+ ui.deafen.setAttribute("aria-pressed", String(on));
+ ui.deafen.textContent = on ? "undeafen" : "deafen";
+ }
+
+ ui.form.addEventListener("submit", (e) => {
+ e.preventDefault();
+ const nickname = ui.nickname.value.trim();
+ const password = ui.password.value;
+ if (nickname.length < 3) return setText(ui.dialogMessage, "Nickname must be 3-30 characters.", "error");
+ if (!password) return setText(ui.dialogMessage, "The server password is required.", "error");
+ store.set(STORE.nickname, nickname);
+ store.set(STORE.password, password);
+ updateTopbarSummary();
+ const targetToJoin = pendingJoin;
+ closeLoginModal();
+ if (targetToJoin !== undefined && !session) {
+ void startJoin(targetToJoin);
+ }
+ });
+
+ ui.leave.addEventListener("click", () => teardown("You left voice."));
+
+ ui.mute.addEventListener("click", () => {
+ if (!session) return;
+ session.muted = !session.muted;
+ setMuteUi(session.muted);
+ sendJson({ type: "mute", muted: session.muted });
+ });
+
+ ui.deafen.addEventListener("click", () => {
+ if (!session) return;
+ session.deafened = !session.deafened;
+ setDeafenUi(session.deafened);
+ if (session.output) session.output.gain.value = session.deafened ? 0 : Number(ui.volume.value) / 100;
+ sendJson({ type: "deafen", deafened: session.deafened });
+ });
+
+ ui.settingsToggle.addEventListener("click", () => {
+ const open = ui.settings.hidden;
+ ui.settings.hidden = !open;
+ ui.settingsToggle.setAttribute("aria-expanded", String(open));
+ });
+
+ ui.mode.addEventListener("click", () => {
+ mode = mode === "ptt" ? "vad" : "ptt";
+ store.set(STORE.mode, mode);
+ applyModeUi();
+ });
+
+ ui.sensitivity.addEventListener("input", () => {
+ store.set(STORE.sensitivity, ui.sensitivity.value);
+ updateThresholdMark();
+ });
+
+ ui.volume.addEventListener("input", () => {
+ store.set(STORE.volume, ui.volume.value);
+ if (session && session.output && !session.deafened) session.output.gain.value = Number(ui.volume.value) / 100;
+ });
+
+ const setPtt = (down) => {
+ if (!session) return;
+ session.pttDown = down;
+ ui.ptt.classList.toggle("is-active", down);
+ };
+ ui.ptt.addEventListener("pointerdown", (e) => { e.preventDefault(); setPtt(true); });
+ for (const ev of ["pointerup", "pointerleave", "pointercancel"]) ui.ptt.addEventListener(ev, () => setPtt(false));
+
+ const typingTarget = (t) => t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
+ document.addEventListener("keydown", (e) => {
+ if (e.code === PTT_KEY && mode === "ptt" && session && !e.repeat && !typingTarget(e.target)) setPtt(true);
+ });
+ document.addEventListener("keyup", (e) => { if (e.code === PTT_KEY) setPtt(false); });
+ window.addEventListener("blur", () => setPtt(false));
+
+ window.addEventListener("pagehide", () => { if (session) teardown(""); });
+})();
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage