From 96bfe2e1505b4ae250f2d5201f9541ceb01cec84 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Thu, 29 Oct 2026 19:58:05 -0700 Subject: ts web slopify code --- static/mic-worklet.js | 60 +++ static/voice.js | 1145 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1205 insertions(+) create mode 100644 static/mic-worklet.js create mode 100644 static/voice.js (limited to 'static') 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(""); }); +})(); -- cgit v1.2.3