"use strict"; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const SENDABLE_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]); const TEXT_TYPES = new Set([0, 5]); const VOICE_TYPES = new Set([2, 13]); const state = { user: null, guilds: [], guildId: null, channelId: null, channel: null, messages: [], dms: [], voiceOverview: null, voiceSelf: null, health: null, eventCount: 0, lastEventSample: null, eventRate: 0, events: [], visibleGuilds: null, visibleGuildsSaved: null, timeZoneOverride: localStorage.getItem("veskslop-timezone") ?? "", speech: { provider: null, language: null, voice: null }, profileDefaults: {}, expressionDefault: null, scope: "guild", speaking: new Set(), speakingFade: new Map(), people: new Map(), favourites: { users: [], channels: [] }, favouriteUsers: [], speakingRenderAt: 0, daemonDown: false, composerChannelId: null, audioRecording: false, clientAudio: { on: false, ws: null, ctx: null, nextTime: 0, sinkId: localStorage.getItem("veskslop-browser-output") ?? "" }, takeover: localStorage.getItem("veskslop-takeover") === "1", consoleOpen: false, consoleTab: "events", mobileTab: localStorage.getItem("veskslop-mobile-tab") ?? "chat", voiceReloadAt: 0 }; /* Drafts survive re-renders and reloads. Nothing else is persisted client-side. */ const draftKey = channelId => `veskslop-draft:${channelId}`; const TTS_DRAFT_KEY = "veskslop-tts-draft"; function isGuildVisible(guildId) { return !state.visibleGuilds || state.visibleGuilds.has(guildId); } function visibleGuildsList() { return state.guilds.filter(guild => isGuildVisible(guild.id)); } /** All guilds visible is stored as null. */ function normalizedVisibility(visibility) { if (!visibility) return null; if (state.guilds.length > 0 && visibility.size === state.guilds.length) return null; return visibility; } function sameVisibility(a, b) { if (!a || !b) return !a && !b; if (a.size !== b.size) return false; for (const id of a) if (!b.has(id)) return false; return true; } function updateGuildsSaveButton() { const button = $("#guilds-save"); if (button) button.disabled = sameVisibility(state.visibleGuilds, state.visibleGuildsSaved); } async function loadPreferences() { try { const prefs = await VK.api.get("/api/preferences"); const visibility = Array.isArray(prefs.visibleGuilds) ? new Set(prefs.visibleGuilds) : null; state.visibleGuilds = visibility ? new Set(visibility) : null; state.visibleGuildsSaved = visibility ? new Set(visibility) : null; } catch { state.visibleGuilds = null; state.visibleGuildsSaved = null; } updateGuildsSaveButton(); } /** Persist the current checkbox selection to the daemon state. */ async function saveGuildVisibility() { const visibility = normalizedVisibility(state.visibleGuilds); const visibleGuilds = visibility ? [...visibility] : null; try { const prefs = await VK.api.post("/api/preferences", { visibleGuilds }); state.visibleGuildsSaved = Array.isArray(prefs.visibleGuilds) ? new Set(prefs.visibleGuilds) : null; state.visibleGuilds = state.visibleGuildsSaved ? new Set(state.visibleGuildsSaved) : null; renderVisibleGuilds(); renderGuildPicker(); renderGuildSwitcher(); toast("Guild visibility saved", "ok"); } catch (error) { toast(error.message, "error"); } } /* Helpers */ function el(tag, props = {}, ...children) { const node = document.createElement(tag); for (const [key, value] of Object.entries(props)) { if (value === undefined || value === null) continue; if (key === "class") node.className = value; else if (key === "text") node.textContent = value; else if (key === "html") node.innerHTML = value; else if (key.startsWith("on")) node.addEventListener(key.slice(2).toLowerCase(), value); else if (key === "disabled") node.disabled = !!value; else node.setAttribute(key, value); } for (const child of children.flat()) { if (child === undefined || child === null) continue; node.append(child.nodeType ? child : document.createTextNode(String(child))); } return node; } function icon(name, size = 13) { const span = el("span", { class: "vk-icon" }); span.innerHTML = VK.icon(name, size); return span; } function hydrateIcons(root = document) { for (const span of $$("[data-icon]", root)) span.innerHTML = VK.icon(span.dataset.icon); } function toast(message, kind = "") { const node = el("div", { class: `vk-toast ${kind}`, text: message }); $("#toasts").append(node); setTimeout(() => node.remove(), 5000); } function monogram(name) { const clean = String(name ?? "?").replace(/[^a-zA-Z0-9]/g, ""); return (clean[0] ?? "?").toUpperCase(); } const CDN_BASE = "https://cdn.discordapp.com"; function cdnHashUrl(route, id, hash, size = 64) { if (!hash) return null; const ext = String(hash).startsWith("a_") ? "gif" : "png"; return `${CDN_BASE}/${route}/${id}/${hash}.${ext}?size=${size}`; } function defaultAvatarUrl(id) { try { return `${CDN_BASE}/embed/avatars/${Number(BigInt(String(id)) >> 22n) % 6}.png`; } catch { return null; } } function userAvatarUrl(user, size = 64) { if (!user?.id) return null; return cdnHashUrl("avatars", user.id, user.avatar, size) ?? defaultAvatarUrl(user.id); } function guildIconUrl(guild, size = 64) { return cdnHashUrl("icons", guild.id, guild.icon, size); } function dmIconUrl(dm, size = 64) { if (dm?.icon) return cdnHashUrl("channel-icons", dm.id, dm.icon, size); const recipient = dm?.recipients?.[0]; return recipient ? userAvatarUrl(recipient, size) : null; } function avatar(entity, { agent = false, size = "", url } = {}) { const name = typeof entity === "string" ? entity : entity?.username ?? entity?.name ?? "?"; const resolved = url ?? (typeof entity === "object" && entity ? userAvatarUrl(entity) : null); const node = el("span", { class: `vk-avatar ${agent ? "agent" : ""} ${size}`.trim() }); if (resolved) { const img = el("img", { src: resolved, alt: "", loading: "lazy" }); img.addEventListener("error", () => { img.remove(); node.textContent = monogram(name); }); node.append(img); } else { node.textContent = monogram(name); } return node; } function dmAvatar(dm, opts = {}) { return avatar(dmLabel(dm), { ...opts, url: dmIconUrl(dm) }); } function guildMarkContent(mark, guild) { const url = guildIconUrl(guild); if (!url) { mark.textContent = monogram(guild.name); return; } const img = el("img", { src: url, alt: "", loading: "lazy" }); img.addEventListener("error", () => { img.remove(); mark.textContent = monogram(guild.name); }); mark.append(img); } function browserTimeZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } } function formatDate(timestamp, options) { const date = new Date(timestamp); if (Number.isNaN(date.getTime())) return ""; return new Intl.DateTimeFormat(undefined, { ...options, timeZone: state.timeZoneOverride || undefined }).format(date); } function formatTime(timestamp) { return formatDate(timestamp, { hour: "2-digit", minute: "2-digit" }); } function formatClock(timestamp) { return formatDate(timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function formatDateTime(timestamp) { return formatDate(timestamp, { dateStyle: "medium", timeStyle: "medium" }); } function updateTimeZoneStatus() { const status = $("#timezone-status"); if (!status) return; status.textContent = state.timeZoneOverride ? `override · ${state.timeZoneOverride}` : `auto · ${browserTimeZone()}`; } function renderTimeZones() { renderMessages(); renderEvents(); if (state.consoleTab === "transcript") void refreshTranscript(); if (!$("#settings-overlay").hidden) void refreshLogs(); } const CURATED_ZONES = [ { name: "UTC", abbr: "UTC", zone: "UTC" }, { name: "UK", abbr: "GMT/BST", zone: "Europe/London" }, { name: "Central Europe", abbr: "CET/CEST", zone: "Europe/Berlin" }, { name: "Eastern Europe", abbr: "EET/EEST", zone: "Europe/Bucharest" }, { name: "US East Coast", abbr: "ET", zone: "America/New_York" }, { name: "Ohio", abbr: "ET", zone: "America/New_York" }, { name: "US Central", abbr: "CT", zone: "America/Chicago" }, { name: "US Mountain", abbr: "MT", zone: "America/Denver" }, { name: "US West Coast", abbr: "PT", zone: "America/Los_Angeles" }, { name: "Japan", abbr: "JST", zone: "Asia/Tokyo" }, { name: "India", abbr: "IST", zone: "Asia/Kolkata" }, { name: "Australia", abbr: "AET", zone: "Australia/Sydney" } ]; function initTimeZone() { const select = $("#timezone-select"); const zones = typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : []; const curated = new Set(CURATED_ZONES.map(entry => entry.zone)); select.replaceChildren(el("option", { value: "", text: `Auto (browser: ${browserTimeZone()})` })); select.append( el( "optgroup", { label: "Common" }, ...CURATED_ZONES.map(entry => el("option", { value: entry.zone, text: `${entry.name} (${entry.abbr})` }) ) ) ); select.append( el( "optgroup", { label: "All timezones" }, ...zones.filter(zone => !curated.has(zone)).map(zone => el("option", { value: zone, text: zone })) ) ); if (state.timeZoneOverride && !zones.includes(state.timeZoneOverride) && !curated.has(state.timeZoneOverride)) { select.append(el("option", { value: state.timeZoneOverride, text: state.timeZoneOverride })); } select.value = state.timeZoneOverride; updateTimeZoneStatus(); select.addEventListener("change", () => { state.timeZoneOverride = select.value; if (state.timeZoneOverride) localStorage.setItem("veskslop-timezone", state.timeZoneOverride); else localStorage.removeItem("veskslop-timezone"); updateTimeZoneStatus(); renderTimeZones(); }); } function formatUptime(seconds) { if (!Number.isFinite(seconds)) return "up —"; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (days) return `up ${days}d ${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; if (hours) return `up ${hours}h ${String(minutes).padStart(2, "0")}m`; return `up ${minutes}m`; } function currentGuild() { return state.guilds.find(guild => guild.id === state.guildId) ?? null; } function findGuildChannel(channelId) { for (const guild of state.guilds) { const channel = guild.channels.find(item => item.id === channelId); if (channel) return channel; } return null; } function dmById(channelId) { return state.dms.find(dm => dm.id === channelId) ?? null; } function dmLabel(dm) { if (dm.name) return dm.name; const names = (dm.recipients ?? []).map(recipient => recipient.username); if (!names.length) return "DM"; if (names.length <= 2) return names.join(", "); return `${names[0]} + ${names.length - 1}`; } function channelLabel() { if (!state.channel) return "No channel selected"; return state.channel.name ?? channelLabelFromDm(); } function channelLabelFromDm() { const dm = state.channelId ? dmById(state.channelId) : null; return dm ? dmLabel(dm) : "Direct message"; } /* Data loading */ async function loadState() { let data; try { data = await VK.api.get("/api/state"); } catch { renderIdentity(); renderGuildPicker(); renderGuildSwitcher(); return false; } state.user = data.user ?? null; state.guilds = data.guilds ?? []; const visible = visibleGuildsList(); if (!state.guildId || !visible.some(guild => guild.id === state.guildId)) { state.guildId = visible[0]?.id ?? null; } renderIdentity(); renderGuildPicker(); renderGuildSwitcher(); renderVisibleGuilds(); renderRail(); const guild = currentGuild(); if (!state.channelId && guild) { const first = guild.channels.find(channel => SENDABLE_TYPES.has(channel.type)); if (first) await selectChannel(first.id, guild.id); } else if (state.channelId) { renderComposer(); } return true; } async function loadDms() { try { const result = await VK.api.get("/api/dms"); state.dms = result.channels ?? []; } catch { state.dms = []; } renderDmRail(); renderRightDms(); renderRail(); updateMobileBadge(); } async function loadVoice() { try { state.voiceSelf = await VK.api.get("/api/voice"); } catch { state.voiceSelf = null; } if (state.guildId) { try { const overview = await VK.api.get(`/api/voice/overview?guildId=${encodeURIComponent(state.guildId)}`); state.voiceOverview = overview.channels ?? []; } catch { state.voiceOverview = []; } } else { state.voiceOverview = []; } trackPeople((state.voiceOverview ?? []).flatMap(entry => entry.members ?? [])); renderVoiceRail(); renderPeople(); renderRightVoice(); renderCallStrip(); } async function loadMessages({ refresh = false } = {}) { if (!state.channelId) { state.messages = []; renderMessages(); return; } const channelId = state.channelId; try { // refresh fetches the latest 50 from Discord and fills the client cache. // Attachments stay CDN links; nothing is downloaded or cached locally. const result = await VK.api.get( `/api/messages?channelId=${encodeURIComponent(channelId)}&limit=50${refresh ? "&refresh=1" : ""}` ); if (channelId !== state.channelId) return; state.messages = result.messages ?? []; } catch (error) { if (channelId !== state.channelId) return; state.messages = []; toast(error.message, "error"); } renderMessages(); } let refreshing = false; /** Reload everything from the daemon without touching any draft input. */ async function refreshAll() { if (refreshing) return; refreshing = true; try { await loadPreferences(); // The bridge can connect before the renderer answers state.get. let loaded = false; for (let attempt = 0; attempt < 15 && !loaded; attempt++) { loaded = await loadState(); if (!loaded) await new Promise(resolve => setTimeout(resolve, 2000)); } if (!loaded) return; await loadDms(); await refreshFavourites(); await loadVoice(); if (state.channelId) await loadMessages({ refresh: true }); } catch (error) { toast(error.message, "error"); } finally { refreshing = false; } } async function selectGuild(guildId) { state.guildId = guildId; state.channelId = null; state.channel = null; state.scope = "guild"; $("#scope-guild").classList.add("active"); $("#scope-dm").classList.remove("active"); $("#guild-menu").hidden = true; renderGuildPicker(); renderGuildSwitcher(); renderRail(); renderRightVoice(); const guild = currentGuild(); const first = guild?.channels.find(channel => SENDABLE_TYPES.has(channel.type)); if (first) await selectChannel(first.id, guildId); await loadVoice(); } async function selectChannel(channelId, guildId) { if (guildId && guildId !== state.guildId) state.guildId = guildId; const switched = Boolean(state.channelId) && state.channelId !== channelId; state.channelId = channelId; state.channel = findGuildChannel(channelId); if (!state.channel) { const dm = dmById(channelId); if (dm) state.channel = { id: dm.id, name: dmLabel(dm), type: dm.type, dm }; } renderGuildPicker(); renderRail(); renderCenterHeader(); renderRightDms(); renderComposer(); await loadMessages({ refresh: true }); await loadVoice(); if (switched && isMobileLayout()) { setMobileTab(VOICE_TYPES.has(state.channel?.type) ? "call" : "chat"); } } async function sendMessage() { if (!state.takeover) { toast("Composer is read-only — turn Takeover on to send", "error"); return; } const input = $("#composer-input"); const content = input?.value.trim() ?? ""; if (!content || !state.channelId) return; try { await VK.api.post("/api/messages", { channelId: state.channelId, content }); localStorage.removeItem(draftKey(state.channelId)); input.value = ""; autoGrow(input); toast("Message sent", "ok"); await loadMessages(); } catch (error) { toast(error.message, "error"); } } function autoGrow(input) { input.style.height = "auto"; input.style.height = `${Math.min(input.scrollHeight, 160)}px`; } /* Header and rail rendering */ function renderIdentity() { const footer = $("#agent-identity"); const name = state.user?.username ?? "not logged in"; footer.replaceChildren( state.user ? avatar(state.user, { agent: true }) : avatar(name, { agent: true }), el( "span", { class: "vk-right-name" }, el("span", { class: "vk-agent-name", text: name }), el("span", { class: "vk-agent-sub", text: "agent · headless" }) ) ); } function renderGuildPicker() { const guild = currentGuild(); $("#guild-picker-label").textContent = state.scope === "dm" ? "Direct messages" : guild?.name ?? "Direct messages"; } function renderGuildSwitcher() { const root = $("#guild-switcher"); root.replaceChildren(); for (const guild of visibleGuildsList()) { const button = el("button", { class: `vk-guild-mark ${guild.id === state.guildId ? "active" : ""}`, type: "button", title: guild.name, onclick: () => void selectGuild(guild.id) }); guildMarkContent(button, guild); root.append(button); } } function setGuildVisible(guildId, visible) { if (!state.visibleGuilds) state.visibleGuilds = new Set(state.guilds.map(guild => guild.id)); if (visible) state.visibleGuilds.add(guildId); else state.visibleGuilds.delete(guildId); if (state.guildId && !isGuildVisible(state.guildId)) { const next = visibleGuildsList()[0]; if (next) void selectGuild(next.id); } renderGuildPicker(); renderGuildSwitcher(); renderVisibleGuilds(); } function renderVisibleGuilds() { const root = $("#guild-visibility"); if (!root) return; root.replaceChildren(); if (!state.guilds.length) { root.append(el("div", { class: "vk-empty", text: "No guilds cached." })); return; } for (const guild of state.guilds) { const checkbox = el("input", { type: "checkbox" }); checkbox.checked = isGuildVisible(guild.id); checkbox.addEventListener("change", () => setGuildVisible(guild.id, checkbox.checked)); const mark = el("span", { class: "vk-guild-icon-sm" }); guildMarkContent(mark, guild); root.append(el("label", { class: "vk-visibility-row" }, checkbox, mark, el("span", { text: guild.name }))); } updateGuildsSaveButton(); } function voiceMarks(member) { const marks = el("span", { class: "vk-marks" }); const muted = member.suppress || member.mute || member.selfMute; // The bars are always in the layout; they scale up while speaking and // decay back to nothing when the voice stops. const classes = ["vk-levels"]; if (!muted && state.speaking.has(member.id)) classes.push("speaking"); if (!muted && state.speakingFade.has(member.id)) classes.push("stopping"); const levels = el("span", { class: classes.join(" "), title: "speaking" }); for (let index = 0; index < 4; index++) levels.append(el("span")); marks.append(levels); if (muted) { const title = member.suppress ? "muted by server" : member.mute ? "server-muted" : "self-muted"; marks.append(el("span", { class: "neutral", title, html: VK.icon("micSlash") })); } if (member.deaf || member.selfDeaf) { marks.append(el("span", { class: "neutral", title: "deafened", html: VK.icon("speakerSlash") })); } if (member.video) { marks.append(el("span", { class: "accent", title: "camera on", html: VK.icon("camera") })); } if (member.stream) { marks.append(el("span", { class: "accent", title: "screen sharing", html: VK.icon("monitor") })); } return marks; } function memberName(member) { return member.nick ?? member.username ?? member.id; } function memberNameNode(member, className = "vk-name") { return el("span", { class: `${className} ${state.speaking.has(member.id) ? "speaking" : ""}`.trim(), text: memberName(member), style: member.id === state.user?.id ? "color: var(--color-accent-300)" : "" }); } /* Speaking marks. Bars stay on until an explicit stop, a mute, or a channel change: Discord's speaking events are not reliable enough for an idle decay. The fade runs the equalizer animation inside a shrinking container, so the amplitude drops instead of the whole block collapsing at full height. */ const SPEAKING_FADE_MS = 750; function renderSpeakingViews() { renderVoiceRail(); renderPeople(); renderRightVoice(); renderRightFavourites(); renderCallStrip(); } function scheduleSpeakingRender() { const now = Date.now(); if (now - state.speakingRenderAt < 250) return; state.speakingRenderAt = now; renderSpeakingViews(); } function beginSpeaking(userId) { const fade = state.speakingFade.get(userId); if (fade) { clearTimeout(fade); state.speakingFade.delete(userId); } const wasSpeaking = state.speaking.has(userId); state.speaking.add(userId); if (!wasSpeaking) scheduleSpeakingRender(); } function endSpeaking(userId) { if (!state.speaking.delete(userId)) return; const fadeTimer = setTimeout(() => { state.speakingFade.delete(userId); renderSpeakingViews(); }, SPEAKING_FADE_MS); state.speakingFade.set(userId, fadeTimer); scheduleSpeakingRender(); } function trackPeople(members) { for (const member of members ?? []) { if (!member?.id || member.id === state.user?.id) continue; state.people.set(member.id, { id: member.id, username: member.username ?? member.id, nick: member.nick ?? member.username ?? member.id, avatar: member.avatar ?? null, at: Date.now() }); } } function renderRail() { const guildScope = state.scope === "guild"; $("#rail-guild").hidden = !guildScope; $("#rail-dm").hidden = guildScope; if (guildScope) { renderTextChannels(); renderVoiceRail(); renderPeople(); } else { renderDmRail(); } } function renderTextChannels() { const root = $("#text-channels"); root.replaceChildren(); const guild = currentGuild(); if (!guild) return; const channels = guild.channels.filter(channel => TEXT_TYPES.has(channel.type)); if (!channels.length) { root.append(el("div", { class: "vk-empty", text: "No text channels cached." })); return; } for (const channel of channels) { root.append( el( "button", { class: `vk-rail-row ${channel.id === state.channelId ? "active" : ""}`, type: "button", onclick: () => void selectChannel(channel.id) }, el("span", { class: "vk-icon vk-dim", html: VK.icon("hash") }), el("span", { class: "vk-rail-name", text: channel.name }) ) ); } } function renderVoiceRail() { const root = $("#voice-channels"); if (!root) return; root.replaceChildren(); const guild = currentGuild(); if (!guild) return; const voiceChannels = guild.channels.filter(channel => VOICE_TYPES.has(channel.type)); if (!voiceChannels.length) { root.append(el("div", { class: "vk-empty", text: "No voice channels cached." })); return; } const occupied = new Map((state.voiceOverview ?? []).map(entry => [entry.channelId, entry])); for (const channel of voiceChannels) { const call = occupied.get(channel.id); const active = state.voiceSelf?.channelId === channel.id; if (call?.members?.length) { trackPeople(call.members); const block = el("div", { class: "vk-rail-voice" }); block.append( el( "button", { class: "vk-rail-voice-head", type: "button", title: active ? "Leave this call" : "Join this voice channel", onclick: () => void joinVoice(channel.id) }, el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }), el("span", { class: "vk-rail-name", style: "font-weight:500", text: channel.name }), el( "span", { class: "vk-rail-trail" }, el("span", { style: `display:inline-block;width:5px;height:5px;border-radius:50%;margin-right:5px;background:var(--color-${active ? "accent" : "neutral-500"})` }), `${call.members.length}` ) ) ); const members = el("div", { class: "vk-rail-voice-members" }); for (const member of call.members) { members.append( el("span", { class: "vk-voice-member" }, memberNameNode(member, "vk-name"), voiceMarks(member)) ); } block.append(members); root.append(block); } else { root.append( el( "button", { class: `vk-rail-row ${active ? "active" : ""}`, type: "button", title: "Join this voice channel", onclick: () => joinVoice(channel.id) }, el("span", { class: "vk-icon vk-dim", html: VK.icon("volume") }), el("span", { class: "vk-rail-name", text: channel.name }), el("span", { class: "vk-rail-trail", text: "0" }) ) ); } } } function renderPeople() { const root = $("#people-list"); if (!root) return; root.replaceChildren(); const people = [...state.people.values()].sort((a, b) => b.at - a.at).slice(0, 20); if (!people.length) { root.append(el("div", { class: "vk-empty", text: "No voice people seen yet." })); return; } for (const person of people) { root.append( el( "button", { class: "vk-rail-row", type: "button", title: person.username, onclick: () => openPersonDm(person.id) }, avatar(person, { size: "sm" }), el("span", { class: `vk-rail-name ${state.speaking.has(person.id) ? "speaking" : ""}`.trim(), text: person.nick ?? person.username }) ) ); } } function openPersonDm(userId) { const dm = state.dms.find( entry => entry.type === 1 && (entry.recipients ?? []).some(recipient => recipient.id === userId) ); if (!dm) { toast("No cached DM with that user", "error"); return; } void openDm(dm); } function dmStarred(dm) { if (dm.type === 3) return state.favourites.channels.includes(dm.id); const recipient = dm.recipients?.[0]?.id; return !!recipient && state.favourites.users.includes(recipient); } function starButton(active, onToggle) { const button = el("button", { class: `vk-star ${active ? "on" : ""}`, type: "button", title: active ? "Remove from favourites" : "Add to favourites", onclick: event => { event.stopPropagation(); onToggle(); } }); button.innerHTML = VK.icon("star", 12); return button; } function unreadBadge(dm) { const mentions = dm.mentions ?? 0; const unread = dm.unread ?? 0; if (!mentions && !unread) return null; const kind = !state.takeover ? "muted" : mentions ? "mention" : "unread"; return el("span", { class: `vk-dm-badge ${kind}`, text: mentions ? String(mentions) : unread > 99 ? "99+" : String(unread) }); } function formatDmTime(iso) { if (!iso) return ""; const date = new Date(iso); if (Number.isNaN(date.getTime())) return ""; const now = new Date(); if (date.toDateString() === now.toDateString()) return formatTime(iso); const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); if (date.toDateString() === yesterday.toDateString()) return "yesterday"; return formatDate(iso, { month: "short", day: "numeric" }); } function dmRow(dm) { const active = dm.id === state.channelId; const row = el("button", { class: `vk-rail-row ${active ? "active" : ""}`, type: "button", onclick: () => void openDm(dm) }); const badge = unreadBadge(dm); if (badge) row.append(badge); row.append(dmAvatar(dm, { size: "sm" })); row.append(el("span", { class: "vk-rail-name", text: dmLabel(dm) })); const time = formatDmTime(dm.lastMessageAt); if (time) row.append(el("span", { class: "vk-dm-time", text: time })); row.append(starButton(dmStarred(dm), () => void toggleFavouriteDm(dm))); return row; } function renderDmRail() { const root = $("#dm-rail-list"); if (!root) return; root.replaceChildren(); if (!state.dms.length) { root.append(el("div", { class: "vk-empty", text: "No cached DMs." })); return; } for (const dm of state.dms) root.append(dmRow(dm)); } async function openDm(dm) { await applyScope("dm"); await selectChannel(dm.id); } async function applyScope(scope) { state.scope = scope; $("#scope-guild").classList.toggle("active", scope === "guild"); $("#scope-dm").classList.toggle("active", scope === "dm"); renderGuildPicker(); if (scope === "dm") { if (!state.channel?.dm) { const first = state.dms[0]; if (first) await selectChannel(first.id); } } else if (state.channel?.dm) { const guild = currentGuild(); const first = guild?.channels.find(channel => SENDABLE_TYPES.has(channel.type)); if (first) await selectChannel(first.id); } renderRail(); } async function saveFavourites(patch) { try { const prefs = await VK.api.post("/api/preferences", { favourites: { ...state.favourites, ...patch } }); state.favourites = prefs.favourites ?? { users: [], channels: [] }; await refreshFavourites(); renderDmRail(); renderRightDms(); } catch (error) { toast(error.message, "error"); } } function toggleFavouriteDm(dm) { if (dm.type === 3) { const channels = state.favourites.channels.includes(dm.id) ? state.favourites.channels.filter(id => id !== dm.id) : [...state.favourites.channels, dm.id]; void saveFavourites({ channels }); return; } const userId = dm.recipients?.[0]?.id; if (!userId) { toast("No cached recipient to favourite", "error"); return; } const users = state.favourites.users.includes(userId) ? state.favourites.users.filter(id => id !== userId) : [...state.favourites.users, userId]; void saveFavourites({ users }); } function removeFavouriteUser(userId) { void saveFavourites({ users: state.favourites.users.filter(id => id !== userId) }); } /* Center rendering */ function renderCenterHeader() { const title = channelLabel(); const channel = state.channel; const guild = currentGuild(); $("#channel-title").textContent = title; $("#channel-icon").innerHTML = VK.icon( channel && VOICE_TYPES.has(channel.type) ? "volume" : channel?.dm ? "speaker" : "hash" ); let context = ""; if (channel?.dm) { const count = channel.dm.recipients?.length ?? 0; context = channel.dm.type === 3 ? `group · ${count} recipients` : "direct message"; } else if (guild) { context = `${guild.name} · ${guild.channels.length} channels cached`; } $("#channel-context").textContent = context; } function renderMessages() { const root = $("#message-list"); root.replaceChildren(); if (!state.channelId) { root.append(el("div", { class: "vk-empty", text: "Pick a channel on the left." })); return; } if (!state.messages.length) { root.append( el("div", { class: "vk-empty", text: "No cached messages. The client only holds channels it has loaded." }) ); return; } for (const message of state.messages) { const author = message.author?.username ?? "unknown"; const isAgent = message.author?.id === state.user?.id; const body = el( "div", { class: "vk-message-body-wrap" }, el( "span", { class: "vk-message-head" }, el("span", { class: "vk-message-author", text: author }), isAgent ? el("span", { class: "tag tag-outline vk-tag-agent", text: "agent" }) : null, el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) }), message.editedAt ? el("span", { class: "vk-mono vk-dim", text: "· edited" }) : null ), message.replyTo ? el("span", { class: "vk-message-reply", text: `↳ replying to ${message.replyTo.authorName ?? message.replyTo.authorId ?? "a message"}` }) : null, el("span", { class: "vk-message-body", text: message.content }) ); for (const attachment of message.attachments ?? []) { if (!attachment) continue; const filename = attachment.filename ?? "attachment"; const isImage = String(attachment.contentType ?? "").startsWith("image/") || /\.(png|jpe?g|gif|webp|avif)$/i.test(filename); if (isImage && attachment.url) { const image = el("img", { class: "vk-message-image", src: attachment.url, alt: filename, loading: "lazy" }); image.addEventListener("error", () => image.remove()); body.append(image); } else if (attachment.url) { body.append( el("a", { class: "vk-attachment", href: attachment.url, target: "_blank", rel: "noreferrer", text: filename }) ); } else if (filename) { body.append(el("span", { class: "vk-attachment", text: filename })); } } root.append(el("div", { class: "vk-message" }, avatar(message.author ?? author, { agent: isAgent, size: "lg" }), body)); } const scroll = $("#message-scroll"); scroll.scrollTop = scroll.scrollHeight; } function ttsToggleButton() { return el("button", { id: "tts-toggle", class: "btn btn-secondary vk-icon-btn vk-tts-toggle", type: "button", title: "Text to speech", onclick: toggleTts, html: VK.icon("wave") }); } function composerPlaceholder() { const target = channelLabel() === "No channel selected" ? "" : channelLabel(); return `Message ${target} as ${state.user?.username ?? "the account"}`; } function renderComposer() { const wrap = $("#composer"); const existing = $("#composer-input"); if (state.takeover) { wrap.classList.add("unlocked"); // Reuse the live input so typing and caret survive re-renders. if (existing && state.composerChannelId === state.channelId) { existing.placeholder = composerPlaceholder(); return; } const draft = state.channelId ? localStorage.getItem(draftKey(state.channelId)) ?? "" : ""; const input = el("textarea", { id: "composer-input", class: "vk-composer-input", rows: "1", placeholder: composerPlaceholder() }); input.value = draft; input.addEventListener("input", () => { autoGrow(input); if (state.channelId) localStorage.setItem(draftKey(state.channelId), input.value); }); input.addEventListener("keydown", event => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }); state.composerChannelId = state.channelId; wrap.replaceChildren( ttsToggleButton(), input, el("button", { id: "send", class: "btn btn-primary vk-send", type: "button", text: "Send", onclick: () => void sendMessage() }) ); requestAnimationFrame(() => autoGrow(input)); return; } state.composerChannelId = null; wrap.classList.remove("unlocked"); wrap.replaceChildren( el("span", { class: "tag tag-neutral vk-readonly-tag", text: "read-only" }), icon("lock"), el("span", { class: "vk-composer-copy", text: `Composer locked — takeover is off. Flip Takeover to send as ${state.user?.username ?? "the account"}.` }), ttsToggleButton(), el("button", { class: "btn btn-secondary vk-send", type: "button", text: "Send", disabled: true }) ); } function toggleTts() { const panel = $("#tts-panel"); panel.hidden = !panel.hidden; $("#tts-target").textContent = `as ${state.user?.username ?? "account"} → ${channelLabel()}`; if (!panel.hidden) void refreshSpeech(); } /* Right column */ function renderRightVoice() { const root = $("#right-voice"); if (!root) return; root.replaceChildren(); // The phone Call tab shows the live call, not just favourites. const callMembers = isMobileLayout() && state.mobileTab === "call" ? (state.voiceSelf?.members ?? []) : []; if (callMembers.length) { $("#voice-count").textContent = `${callMembers.length} in call`; const card = el( "div", { class: "vk-right-group" }, el( "div", { class: "vk-right-group-head" }, el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }), el("span", { class: "vk-name", text: state.voiceSelf?.channelName ?? "call" }) ), ...callMembers.map(member => el( "div", { class: "vk-right-row" }, avatar(member, { size: "sm" }), el( "span", { class: "vk-right-name" }, el("span", { class: `vk-name ${state.speaking.has(member.id) ? "speaking" : ""}`.trim(), text: member.username ?? member.id }), el("span", { class: "vk-sub", text: member.id === state.user?.id ? "this client" : member.status ?? "" }) ), el("span", { class: "vk-right-trail" }, voiceMarks(member)) ) ) ); root.append(card); return; } const inCall = state.favouriteUsers.filter(user => user.channelId); $("#voice-count").textContent = inCall.length ? `${inCall.length} in call` : ""; if (!inCall.length) { root.append(el("div", { class: "vk-empty", text: "No favourited users in a call." })); return; } const groups = new Map(); for (const user of inCall) { const list = groups.get(user.channelId) ?? []; list.push(user); groups.set(user.channelId, list); } for (const [channelId, users] of groups) { const card = el( "div", { class: "vk-right-group" }, el( "div", { class: "vk-right-group-head" }, el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }), el("span", { class: "vk-name", text: users[0].channelName ?? channelId }) ) ); for (const user of users) { card.append( el( "div", { class: "vk-right-row" }, avatar({ id: user.id, username: user.username, avatar: user.avatar }, { size: "sm" }), el( "span", { class: "vk-right-name" }, el("span", { class: `vk-name ${state.speaking.has(user.id) ? "speaking" : ""}`.trim(), text: user.username }), el("span", { class: "vk-sub", text: user.status }) ), el( "span", { class: "vk-right-trail" }, el("button", { class: "btn btn-primary vk-small-btn", type: "button", text: "Join", onclick: () => joinVoice(channelId) }) ) ) ); } root.append(card); } } function renderRightDms() { const root = $("#right-dms"); if (!root) return; root.replaceChildren(); $("#dm-count").textContent = state.dms.length ? `${state.dms.length} threads` : ""; if (!state.dms.length) { root.append(el("div", { class: "vk-empty", text: "No cached DMs." })); return; } for (const dm of state.dms.slice(0, 8)) root.append(dmRow(dm)); } function renderCallStrip() { const strip = $("#call-strip"); const call = state.voiceSelf; if (!call?.channelId || call.channelId === state.channelId) { strip.hidden = true; strip.replaceChildren(); return; } strip.replaceChildren( el( "span", { class: "vk-call-title" }, el("span", { class: "vk-call-live" }), el("span", { class: "vk-call-name", text: call.channelName ?? call.channelId }), el("span", { class: "vk-mono vk-dim", text: `${call.members?.length ?? 0} in call` }) ), el( "div", { class: "vk-call-pills" }, ...(call.members ?? []).map(member => el( "span", { class: "vk-strip-pill" }, avatar(member, { size: "sm" }), memberNameNode(member, "vk-strip-name"), voiceMarks(member) ) ) ), el( "div", { class: "vk-call-actions" }, el("button", { class: "btn btn-secondary vk-small-btn", type: "button", text: "Return to call", onclick: () => void selectChannel(call.channelId) }), el("button", { class: "btn btn-secondary vk-small-btn", type: "button", text: "Leave", onclick: () => voiceAction("/api/voice/leave") }) ) ); strip.hidden = false; } async function joinVoice(channelId) { if (state.voiceSelf?.channelId === channelId) { await voiceAction("/api/voice/leave"); return; } await voiceAction("/api/voice/join", { channelId }); } async function voiceAction(path, body) { try { await VK.api.post(path, body ?? {}); toast("Voice action accepted", "ok"); await loadVoice(); } catch (error) { toast(error.message, "error"); } } /* Console */ function eventLine(entry) { return el( "div", { class: "vk-event-line" }, el("span", { class: "vk-event-time", text: formatClock(entry.at) }), el("span", { class: "vk-event-type", text: entry.event.type ?? "event" }), el("span", { text: JSON.stringify(entry.event) }) ); } function renderEvents() { const log = $("#event-log"); if (!log) return; log.replaceChildren(...state.events.slice(0, 200).map(eventLine)); } function addEvent(event) { state.events.unshift({ at: Date.now(), event }); if (state.events.length > 300) state.events.pop(); state.eventCount++; const log = $("#event-log"); if (log) { log.prepend(eventLine(state.events[0])); while (log.childElementCount > 200) log.lastElementChild.remove(); } const ticker = $("#ticker-line"); if (ticker) ticker.textContent = `${event.type ?? "event"} ${JSON.stringify(event).slice(0, 180)}`; syncConsoleCount(); } function syncConsoleCount() { const count = $("#console-count"); if (count) count.textContent = `${state.eventCount} events`; } function setConsoleOpen(open) { state.consoleOpen = open; $("#console-drawer").hidden = !open; $("#console-caret").innerHTML = VK.icon(open ? "caretDown" : "caretUp"); } function setConsoleTab(tab) { state.consoleTab = tab; for (const button of $$(".vk-console-tab")) button.classList.toggle("active", button.dataset.consoleTab === tab); for (const panel of $$(".vk-console-panel")) panel.hidden = panel.id !== `console-panel-${tab}`; if (!state.consoleOpen) setConsoleOpen(true); if (tab === "transcript") void refreshTranscript(); } /* Mobile layout (1c): one pane at a time behind a four-tab bar. */ const MOBILE_QUERY = window.matchMedia("(max-width: 768px)"); const MOBILE_TABS = new Set(["users", "chat", "call", "activity"]); function isMobileLayout() { return MOBILE_QUERY.matches; } function setMobileTab(tab) { if (!MOBILE_TABS.has(tab)) return; state.mobileTab = tab; localStorage.setItem("veskslop-mobile-tab", tab); for (const button of $$(".vk-tab")) button.classList.toggle("active", button.dataset.mobileTab === tab); if (isMobileLayout()) $(".vk-app").dataset.mobileTab = tab; renderRightVoice(); updateMobileBadge(); } function syncMobileLayout() { const app = $(".vk-app"); if (!app) return; if (isMobileLayout()) app.dataset.mobileTab = state.mobileTab; else delete app.dataset.mobileTab; for (const button of $$(".vk-tab")) { button.classList.toggle("active", button.dataset.mobileTab === state.mobileTab); } } function updateMobileBadge() { const badgeEl = $("#mobile-chat-badge"); if (!badgeEl) return; const total = state.dms.reduce((sum, dm) => sum + ((dm.unread ?? 0) || (dm.mentions ?? 0)), 0); badgeEl.hidden = total === 0; badgeEl.textContent = total > 99 ? "99+" : String(total); } /* Settings */ function openSettings() { $("#settings-overlay").hidden = false; void refreshCoreStatus(); void refreshAuth(); void refreshLogs(); void refreshAudio(); } function closeSettings() { $("#settings-overlay").hidden = true; } function statusRow(label, value) { return el( "div", { class: "vk-status-row" }, el("span", { class: "vk-status-label", text: label }), el("span", { class: "vk-status-value" }, value) ); } function badge(text, kind = "") { return el("span", { class: `tag ${kind === "ok" ? "tag-accent" : "tag-neutral"}`, text }); } function renderCoreStatus(status) { const card = $("#core-status"); const process = status.process ?? {}; const rows = [ statusRow("bridge", badge(status.bridgeConnected ? "connected" : "disconnected", status.bridgeConnected ? "ok" : "")), statusRow("process", badge(process.running ? "running" : "stopped", process.running ? "ok" : "")), statusRow("pid", el("code", { text: process.pid ?? "—" })), statusRow("managed", el("span", { text: process.managed ? "yes" : process.running ? "external" : "no" })), statusRow("started", el("span", { text: process.startedAt ? formatDateTime(process.startedAt) : "—" })), statusRow("command", el("code", { text: process.command ?? "—" })) ]; if (process.lastExit) { const exit = process.lastExit; const text = exit.signal ? `signal ${exit.signal}` : `code ${exit.code ?? "?"}`; rows.push(statusRow("last exit", badge(text, exit.code === 0 ? "ok" : ""))); } card.replaceChildren(...rows); if (process.output?.length) { card.append(el("pre", { class: "vk-core-output", text: process.output.join("\n") })); } } async function refreshCoreStatus() { try { renderCoreStatus(await VK.api.get("/api/core/status")); } catch (error) { toast(error.message, "error"); } } async function coreAction(path, label) { try { renderCoreStatus(await VK.api.post(path)); toast(`${label} requested`, "ok"); void refreshHealth(); } catch (error) { toast(error.message, "error"); } } function renderAuth(status) { const card = $("#auth-status"); const session = status.session; const rows = [ statusRow("token file", el("code", { text: status.tokenFile })), statusRow("token", badge(status.hasToken ? "present" : "missing", status.hasToken ? "ok" : "")), statusRow("session", el("span", { text: session?.state ?? "none" })) ]; if (session?.user) rows.push(statusRow("user", el("span", { text: `${session.user.tag} — ${session.user.id}` }))); if (session?.captcha) rows.push(statusRow("captcha sitekey", el("code", { text: session.captcha.sitekey ?? "—" }))); if (session?.error) rows.push(statusRow("error", el("span", { text: session.error }))); card.replaceChildren(...rows); const qr = $("#auth-qr"); if (status.qrDataUrl) { qr.replaceChildren(el("img", { src: status.qrDataUrl, alt: "Discord login QR" })); qr.hidden = false; } else { qr.replaceChildren(); qr.hidden = true; } $("#auth-captcha").hidden = session?.state !== "captcha"; } async function refreshAuth() { try { renderAuth(await VK.api.get("/api/auth")); } catch (error) { toast(error.message, "error"); } } async function authAction(path, label) { try { renderAuth(await VK.api.post(path)); if (label) toast(label, "ok"); } catch (error) { toast(error.message, "error"); } } async function refreshLogs() { const root = $("#action-log"); try { const entries = await VK.api.get("/api/logs?limit=100"); root.replaceChildren(); if (!entries.length) { root.append(el("div", { class: "vk-empty", text: "Action log is empty." })); return; } for (const entry of [...entries].reverse()) { root.append( el( "div", { class: "vk-log-line" }, el("span", { class: "vk-log-time", text: entry.timestamp ? formatClock(entry.timestamp) : "" }), el("span", { class: "vk-log-type", text: entry.type ?? "?" }), el("span", { text: JSON.stringify(entry).slice(0, 220) }) ) ); } } catch (error) { root.replaceChildren(el("div", { class: "vk-empty", text: error.message })); } } /* Audio */ function fillDeviceSelect(select, devices, defaultLabel) { const current = select.value; select.replaceChildren(); if (!devices.length) { select.append(el("option", { value: "", text: "No devices reported", disabled: true })); return; } select.append(el("option", { value: "", text: defaultLabel })); for (const device of devices) { select.append(el("option", { value: device.id, text: device.label })); } if ([...select.options].some(option => option.value === current)) select.value = current; } async function refreshAudio() { try { const status = await VK.api.get("/api/audio"); state.audioRecording = !!status.capturing; $("#audio-status").textContent = status.available ? `ok · ${status.backend ?? "pulse"} · duck ${status.duckPercent}%${status.capturing ? " · capturing" : ""}` : (status.error ?? "unavailable"); const hintRow = $("#audio-hint-row"); hintRow.hidden = !status.hint; $("#audio-hint").textContent = status.hint ?? ""; const tts = status.tts ?? {}; $("#audio-tts").textContent = `local ${tts.local ? "yes" : "no"} · api ${ tts.api ? "yes" : "no" } · player ${status.player ? "yes" : "no"} · queue ${status.queue ?? 0}`; $("#audio-capture").textContent = status.capturing ? "Stop capture" : "Start capture"; const duck = $("#tts-duck"); if (duck) { const on = status.duckEnabled !== false; duck.classList.toggle("vk-chip-on", on); duck.classList.toggle("vk-chip-off", !on); duck.textContent = on ? "duck on voice" : "duck off"; } } catch (error) { $("#audio-status").textContent = error.message; } await refreshBrowserAudio(); await refreshSpeech(); } /* Speech settings (server-side preferences). */ async function refreshSpeech() { let engines = []; let defaultProvider = "local"; try { const status = await VK.api.get("/api/audio"); const tts = status.tts ?? {}; const stt = status.stt ?? {}; engines = tts.engines ?? []; defaultProvider = tts.defaultProvider ?? "local"; $("#speech-status").textContent = `${tts.engine ?? "no local engine"}${tts.api ? " + api" : ""}`; $("#speech-stt").textContent = stt.local ? `local ready${stt.engine ? ` (${stt.engine})` : ""}` : stt.api ? "API configured" : "not configured"; } catch { // the daemon status is optional here } try { const prefs = await VK.api.get("/api/preferences"); state.speech = prefs.tts ?? { provider: null, language: null, voice: null, speed: null, pitch: null }; } catch { // preferences are optional } const selected = resolveEngine(state.speech.provider, engines, defaultProvider); const selectedEngine = engines.find(engine => engine.id === selected); const hasLanguage = selectedEngine?.language !== false; fillEngineOptions($("#speech-provider"), engines, state.speech.provider ?? selected); fillEngineOptions($("#tts-provider"), engines, state.speech.provider ?? selected); let groups = []; try { groups = await VK.api.get(`/api/audio/tts/voices?engine=${encodeURIComponent(selected)}`); } catch { // voices need an engine } const languageSelect = $("#speech-language"); languageSelect.replaceChildren(el("option", { value: "", text: "Auto" })); for (const group of groups) { languageSelect.append(el("option", { value: group.id, text: `${group.name} (${group.id})` })); } languageSelect.value = state.speech.language ?? ""; $("#speech-language-row").hidden = !hasLanguage; const voices = voiceList(groups, hasLanguage ? state.speech.language : null, hasLanguage); fillVoiceSelect($("#speech-voice"), voices, state.speech.voice); fillVoiceSelect($("#tts-voice"), voices, state.speech.voice); syncVoiceControls(voices, state.speech.voice); fillSpeechSliders(state.speech); } /** Resolve the engine that will actually run: provider override first, then the auto default. */ function resolveEngine(provider, engines, defaultProvider) { if (provider === "espeak" || provider === "command" || provider === "api") return provider; if (defaultProvider === "espeak" || defaultProvider === "command" || defaultProvider === "api") return defaultProvider; const available = id => engines.some(engine => engine.id === id && engine.available); if (available("command")) return "command"; if (available("espeak")) return "espeak"; return "api"; } function fillEngineOptions(select, engines, value) { if (!select) return; select.replaceChildren(el("option", { value: "", text: "Auto" })); for (const engine of engines) { const option = el("option", { value: engine.id, text: engine.available ? engine.name : `${engine.name} (unavailable)` }); option.disabled = !engine.available; select.append(option); } select.value = value; } function voiceList(groups, language, hasLanguage) { if (!hasLanguage) return (groups ?? []).flatMap(group => group.voices ?? []); if (!language) return []; return groups?.find(group => group.id === language)?.voices ?? []; } function fillVoiceSelect(select, voices, selected) { if (!select) return; select.replaceChildren(el("option", { value: "", text: "Default" })); for (const voice of voices) { const label = `${voice.name}${voice.gender ? ` · ${voice.gender}` : ""}`; select.append(el("option", { value: voice.id, text: label })); } select.value = selected && voices.some(voice => voice.id === selected) ? selected : ""; } function fillVoiceOptions(groups, language, selected) { fillVoiceSelect($("#speech-voice"), voiceList(groups, language, !!language), selected); } async function saveSpeech(extra = {}) { const provider = $("#speech-provider").value || null; const language = $("#speech-language-row").hidden ? (state.speech.language ?? null) : $("#speech-language").value || null; const voice = $("#speech-voice").value || null; const speed = Number($("#tts-speed").value); const pitch = Number($("#tts-pitch").value); const expressionEl = $("#tts-expression"); const expression = expressionEl.dataset.dirty === "1" || typeof state.speech.expression === "number" ? Number(expressionEl.value) : null; try { const prefs = await VK.api.post("/api/preferences", { tts: { provider, language, voice, speed, pitch, expression, ...effectOptions(), ...extra } }); state.speech = prefs.tts ?? { provider: null, language: null, voice: null, speed: null, pitch: null, expression: null }; $("#tts-provider").value = state.speech.provider ?? ""; fillSpeechSliders(state.speech); await refreshSpeech(); toast("Speech settings saved", "ok"); } catch (error) { toast(error.message, "error"); } } async function warmEngine() { const button = $("#speech-warm"); const engine = $("#speech-provider").value || null; const label = $("#speech-provider").selectedOptions[0]?.textContent ?? "engine"; button.disabled = true; button.textContent = "Starting…"; try { const result = await VK.api.post("/api/audio/tts/warm", engine ? { engine } : {}); toast(result.warmed ? `${label} ready in ${result.ms} ms` : `${label} needs no warm-up`, "ok"); $("#speech-status").textContent = result.warmed ? `${label} ready (${result.ms} ms)` : label; } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; button.textContent = "Start engine"; } } function fillSpeechSliders(tts) { const speed = typeof tts?.speed === "number" ? tts.speed : 1; const pitch = typeof tts?.pitch === "number" ? tts.pitch : 0; $("#tts-speed").value = Number.isFinite(speed) ? speed : 1; $("#tts-pitch").value = Number.isFinite(pitch) ? pitch : 0; syncSpeechSliderLabels(); } function syncSpeechSliderLabels() { const speed = Number($("#tts-speed").value); const pitch = Number($("#tts-pitch").value); const pitchSlider = $("#tts-pitch"); $("#tts-speed-value").textContent = `${speed.toFixed(2)}x`; $("#tts-pitch-value").textContent = pitchSlider.disabled ? "n/a" : `${pitch > 0 ? "+" : ""}${pitch} st`; const expressionSlider = $("#tts-expression"); if (expressionSlider) { const explicit = expressionSlider.dataset.dirty === "1" || expressionSlider.dataset.stored === "1"; $("#tts-expression-value").textContent = explicit ? expressionSlider.value : "default"; } for (const button of $$("#tts-speed-presets .vk-chip")) { const active = Math.abs(Number(button.dataset.speed) - speed) < 0.001; button.classList.toggle("vk-chip-on", active); button.classList.toggle("vk-chip-off", !active); } } /* Flextalk profile controls (VOX/HEV kit): profile values, effects, word mode. */ const PROFILE_SPEC = [ ["pitch", 0, 120], ["rate", 0, 100], ["prominences", 0, 100], ["head_size", 0, 100], ["lowering", 0, 100] ]; function buildProfileSliders() { const root = $("#tts-profile-sliders"); if (!root) return; for (const [key, min, max] of PROFILE_SPEC) { const slider = el("input", { type: "range", min: String(min), max: String(max), step: "1" }); slider.id = `tts-profile-${key}`; slider.dataset.key = key; const value = el("span", { class: "vk-mono vk-dim", text: "default" }); root.append( el( "div", { class: "vk-tts-profile-row" }, el("span", { class: "vk-micro-label", text: key.replace("_", " ") }), slider, value ) ); slider.addEventListener("input", () => { const engineDefault = state.profileDefaults?.[key]; const snapped = typeof engineDefault === "number" && Math.abs(Number(slider.value) - engineDefault) <= 2; if (snapped) { slider.value = engineDefault; slider.dataset.dirty = ""; value.textContent = "default"; } else { slider.dataset.dirty = "1"; value.textContent = slider.value; } }); slider.addEventListener("change", () => void saveSpeech()); } } function profileFromControls() { const profile = {}; for (const [key] of PROFILE_SPEC) { const slider = $(`#tts-profile-${key}`); if (slider?.dataset.dirty === "1") profile[key] = Number(slider.value); } return profile; } function fillProfileControls() { const stored = state.speech.profile ?? {}; fillProfileSlots(); for (const [key] of PROFILE_SPEC) { const slider = $(`#tts-profile-${key}`); if (!slider) continue; if (typeof stored[key] === "number") { slider.value = stored[key]; slider.dataset.dirty = "1"; } else { const engineDefault = state.profileDefaults?.[key]; if (typeof engineDefault === "number") slider.value = engineDefault; slider.dataset.dirty = ""; } const value = slider.parentElement?.querySelector(".vk-mono"); if (value) value.textContent = slider.dataset.dirty === "1" ? slider.value : "default"; } const highpass = Number(state.speech.highpass); $("#tts-highpass").value = Number.isFinite(highpass) && state.speech.highpass !== null ? highpass : 40; $("#tts-highpass-value").textContent = `${$("#tts-highpass").value}Hz`; const echo = Number(state.speech.echo); $("#tts-echo").value = Number.isFinite(echo) && state.speech.echo !== null ? echo : 0; $("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off"; $("#tts-echo-delay").value = state.speech.echoDelayMs ?? ""; $("#tts-echo-level").value = state.speech.echoLevel ?? ""; $("#tts-crunch").value = state.speech.crunch ?? ""; $("#tts-crunch-bits").value = state.speech.crunchBits ?? ""; $("#tts-crunch-rate").value = state.speech.crunchRate ?? ""; $("#tts-stream-words").checked = state.speech.streamWords !== false; $("#tts-words").checked = state.speech.words === true; $("#tts-preset").value = state.speech.preset ?? ""; $("#tts-vox").checked = state.speech.vox === true; $("#tts-vox-clean").checked = state.speech.voxClean === true; $("#tts-vox-clean").disabled = state.speech.vox !== true; const gap = Number(state.speech.wordGapMs); $("#tts-word-gap").value = Number.isFinite(gap) && state.speech.wordGapMs !== null ? gap : 120; $("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`; } function fillProfileSlots() { const select = $("#tts-profile-slot"); if (!select) return; const presets = state.speech.profilePresets ?? []; const current = select.value || "0"; select.replaceChildren( ...Array.from({ length: 5 }, (_, index) => { const preset = presets[index]; const label = preset ? `${index + 1} · ${preset.name ?? "profile"}` : `${index + 1} · empty`; return el("option", { value: String(index), text: label }); }) ); select.value = current; const preset = presets[Number(select.value)]; const name = $("#tts-profile-name"); if (name) name.value = preset?.name ?? ""; } function effectOptions() { return { profile: profileFromControls(), highpass: Number($("#tts-highpass").value), echo: Number($("#tts-echo").value), words: $("#tts-words").checked, wordGapMs: Number($("#tts-word-gap").value), vox: $("#tts-vox").checked, voxClean: $("#tts-vox-clean").checked, preset: $("#tts-preset").value || null, echoDelayMs: $("#tts-echo-delay").value ? Number($("#tts-echo-delay").value) : null, echoLevel: $("#tts-echo-level").value ? Number($("#tts-echo-level").value) : null, crunch: $("#tts-crunch").value || null, crunchBits: $("#tts-crunch-bits").value ? Number($("#tts-crunch-bits").value) : null, crunchRate: $("#tts-crunch-rate").value ? Number($("#tts-crunch-rate").value) : null, streamWords: $("#tts-stream-words").checked }; } function syncVoiceControls(voices, selected) { const voice = (voices ?? []).find(entry => entry.id === selected); const pitchable = !voice || voice.supportsPitch !== false; $("#tts-pitch-control").hidden = !pitchable; $("#tts-pitch").disabled = !pitchable; const expressive = !!voice?.supportsExpression; state.profileDefaults = voice?.profileDefaults ?? {}; state.expressionDefault = typeof voice?.expressionDefault === "number" ? voice.expressionDefault : null; if (expressive) fillProfileControls(); { const slider = $("#tts-expression"); const pref = state.speech.expression; if (typeof pref === "number") { slider.value = pref; } else if (typeof voice.expressionDefault === "number") { slider.value = voice.expressionDefault; } else { slider.value = 50; } slider.dataset.stored = typeof pref === "number" ? "1" : "0"; delete slider.dataset.dirty; } syncSpeechSliderLabels(); } /* Browser audio (puppeting) — separate from the bot's own audio devices. */ async function refreshBrowserAudio() { const hint = $("#browser-hint"); const output = $("#browser-output"); if (!navigator.mediaDevices?.enumerateDevices) { hint.textContent = "Browser audio needs HTTPS or localhost."; output.disabled = true; return; } hint.textContent = "The microphone will be used for talk-through later."; try { const devices = await navigator.mediaDevices.enumerateDevices(); const outputs = devices .filter(device => device.kind === "audiooutput") .map(device => ({ id: device.deviceId, label: device.label || "Output device" })); fillDeviceSelect(output, outputs, "Browser default"); output.value = state.clientAudio.sinkId; if (!outputs.length) { hint.textContent = "No devices listed. Press Enable browser audio and allow the microphone prompt."; } } catch (error) { hint.textContent = error.message; } } async function grantBrowserAudio() { if (await ensureBrowserAudio()) toast("Browser audio enabled", "ok"); } /** Get the browser ready for playback: permission, device labels, and a live * AudioContext. Must run inside the click so the context is not suspended. */ async function ensureBrowserAudio() { if (!navigator.mediaDevices?.getUserMedia) { toast("Browser audio needs HTTPS or localhost", "error"); return false; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); for (const track of stream.getTracks()) track.stop(); } catch (error) { toast(`Could not enable browser audio: ${error.message}`, "error"); return false; } if (!state.clientAudio.ctx) { state.clientAudio.ctx = new AudioContext({ sampleRate: 48000 }); applyBrowserSink(); } if (state.clientAudio.ctx.state === "suspended") { await state.clientAudio.ctx.resume().catch(error => { console.warn("[client-audio] could not resume the audio context:", error); }); } await refreshBrowserAudio(); return true; } function applyBrowserSink() { const context = state.clientAudio.ctx; if (!context || typeof context.setSinkId !== "function") return; context.setSinkId(state.clientAudio.sinkId || "").catch(error => { console.warn("[client-audio] could not set the output device:", error); }); } function playPcm(arrayBuffer) { if (!state.clientAudio.ctx) { state.clientAudio.ctx = new AudioContext({ sampleRate: 48000 }); applyBrowserSink(); } const context = state.clientAudio.ctx; if (context.state === "suspended") void context.resume(); const samples = new Int16Array(arrayBuffer); const buffer = context.createBuffer(1, samples.length, 48000); const data = buffer.getChannelData(0); for (let index = 0; index < samples.length; index++) data[index] = samples[index] / 32768; const source = context.createBufferSource(); source.buffer = buffer; source.connect(context.destination); const now = context.currentTime; if (state.clientAudio.nextTime < now + 0.05) state.clientAudio.nextTime = now + 0.05; source.start(state.clientAudio.nextTime); state.clientAudio.nextTime += buffer.duration; } function updateClientAudioButton() { const button = $("#client-audio"); if (!button) return; button.classList.toggle("active", state.clientAudio.on); const label = button.querySelector(".vk-btn-text"); const text = state.clientAudio.on ? "Client audio on" : "Client audio"; if (label) label.textContent = text; else button.textContent = text; const status = $("#browser-audio-status"); if (status) status.textContent = state.clientAudio.on ? "on" : "off"; } function startClientAudio() { const socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/audio`); socket.binaryType = "arraybuffer"; state.clientAudio.ws = socket; socket.onopen = () => socket.send(JSON.stringify({ type: "listen", on: true })); socket.onmessage = event => { if (typeof event.data === "string") { let message; try { message = JSON.parse(event.data); } catch { return; } if (message.type === "error") { toast(message.error, "error"); stopClientAudio(); } else if (message.type === "status" && message.listening) { state.clientAudio.on = true; updateClientAudioButton(); } else if (message.type === "status" && !message.listening) { stopClientAudio(); } return; } if (state.clientAudio.on) playPcm(event.data); }; socket.onclose = () => { state.clientAudio.ws = null; state.clientAudio.on = false; updateClientAudioButton(); }; } function stopClientAudio() { const socket = state.clientAudio.ws; state.clientAudio.ws = null; state.clientAudio.on = false; if (socket) { try { socket.send(JSON.stringify({ type: "listen", on: false })); } catch { // already closing } try { socket.close(); } catch { // already closed } } updateClientAudioButton(); } async function toggleClientAudio() { if (state.clientAudio.on || state.clientAudio.ws) { stopClientAudio(); return; } // Clicking for audio is the consent: unlock the browser in the same gesture // so playback is not blocked by a suspended AudioContext. if (!(await ensureBrowserAudio())) return; startClientAudio(); } function syncTtsControls() { const speak = $("#tts-speak"); if (speak) speak.disabled = !state.takeover; } async function setCapture(start) { try { await VK.api.post("/api/audio/capture", { start }); toast(start ? "Capture started" : "Capture stopped", "ok"); await refreshTranscript(); void refreshAudio(); } catch (error) { toast(error.message, "error"); } } async function speakText() { if (!state.takeover) { toast("Composer is read-only — turn Takeover on to speak", "error"); return; } const text = $("#tts-input").value.trim(); if (!text) return; try { const provider = $("#tts-provider").value; const speed = Number($("#tts-speed").value); const pitch = Number($("#tts-pitch").value); const expressionEl = $("#tts-expression"); const expression = expressionEl.dataset.dirty === "1" ? Number(expressionEl.value) : state.speech.expression ?? undefined; await VK.api.post("/api/audio/speak", { text, provider, speed, pitch, ...(typeof expression === "number" ? { expression } : {}), ...effectOptions() }); $("#tts-status").textContent = "queued"; toast("Queued for speech", "ok"); void refreshAudio(); } catch (error) { toast(error.message, "error"); } } /* Transcript */ function transcriptLine(segment) { const time = formatClock(segment.startedAt); const speaker = segment.speakerName ?? segment.speakerId ?? "call"; const text = segment.text ?? `(audio ${(segment.durationMs / 1000).toFixed(1)}s, no STT)`; return el( "div", { class: "vk-transcript-line", "data-segment": segment.id }, el("span", { class: "vk-transcript-time", text: time }), el("span", { class: "vk-transcript-speaker", text: speaker }), el("span", { class: "vk-transcript-text", text }), segment.wav ? el("span", { class: "vk-transcript-wav", text: segment.wav.split("/").pop() }) : null ); } async function refreshTranscript() { try { const result = await VK.api.get("/api/transcript?limit=200"); const capture = await VK.api.get("/api/audio").catch(() => null); const list = $("#transcript-list"); list.replaceChildren(); const segments = result.segments ?? []; if (!segments.length) { list.append( el("div", { class: "vk-empty", text: "No captured segments yet. Press Start above." }) ); } else { for (const segment of segments) list.append(transcriptLine(segment)); list.scrollTop = list.scrollHeight; } const active = [...(result.sessions ?? [])].reverse().find(session => !session.endedAt); const captureState = capture?.capturing ? "capturing" : "capture off"; $("#transcript-status").textContent = active ? `${captureState} · session ${active.id.slice(-8)} · ${segments.length} segments` : `${captureState} · ${segments.length} segments`; } catch (error) { toast(error.message, "error"); } } /* Health */ async function refreshHealth() { try { const health = await VK.api.get("/api/health"); state.health = health; const now = Date.now(); if (state.lastEventSample) { const seconds = (now - state.lastEventSample.at) / 1000; const delta = (health.events ?? 0) - state.lastEventSample.events; if (seconds > 0) state.eventRate = delta / seconds; } state.lastEventSample = { at: now, events: health.events ?? 0 }; const dot = $("#health-dot"); dot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`; $("#health-bridge").textContent = health.coreConnected ? "bridge ok" : "bridge down"; $("#health-uptime").textContent = formatUptime(health.uptimeSeconds); $("#health-events").textContent = `${state.eventRate.toFixed(1)} ev/s`; const mcpDot = $("#mcp-dot"); mcpDot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`; $("#mcp-state").textContent = health.coreConnected ? "mcp connected" : "mcp offline"; const process = health.process ?? {}; const startButton = $("#core-start-banner"); state.daemonDown = false; document.body.classList.toggle("vk-offline", !health.coreConnected); if (health.coreConnected) { startButton.hidden = true; } else if (process.running) { startButton.hidden = false; startButton.disabled = true; startButton.textContent = "Starting…"; } else { startButton.hidden = false; startButton.disabled = false; startButton.textContent = "Start server"; } // A missed CORE_STATUS, or a renderer that was not ready yet: keep trying. if (health.coreConnected && !state.user) void refreshAll(); } catch { state.daemonDown = true; document.body.classList.add("vk-offline"); $("#health-dot").className = "vk-dot offline"; $("#health-bridge").textContent = "daemon down"; const startButton = $("#core-start-banner"); if (startButton) { startButton.hidden = false; startButton.disabled = false; startButton.textContent = "Refresh"; } } } /* Live events */ function applyHello(event) { if (typeof event.coreConnected !== "boolean") return; $("#health-dot").className = `vk-dot ${event.coreConnected ? "online" : "offline"}`; $("#health-bridge").textContent = event.coreConnected ? "bridge ok" : "bridge down"; const mcpDot = $("#mcp-dot"); if (mcpDot) mcpDot.className = `vk-dot ${event.coreConnected ? "online" : "offline"}`; // The page may have loaded before the bridge was up. if (event.coreConnected && !state.user) void refreshAll(); } function renderRightFavourites() { const root = $("#right-favourites"); if (!root) return; root.replaceChildren(); const users = state.favouriteUsers; const groups = state.dms.filter(dm => dm.type === 3 && state.favourites.channels.includes(dm.id)); if (!users.length && !groups.length) { root.append(el("div", { class: "vk-empty", text: "No favourites yet. Star a DM." })); return; } for (const user of users) { const status = user.channelId ? `in ${user.channelName ?? "a call"}` : user.status; root.append( el( "div", { class: "vk-right-row" }, avatar({ id: user.id, username: user.username, avatar: user.avatar }, { size: "sm" }), el( "span", { class: "vk-right-name" }, el("span", { class: `vk-name ${state.speaking.has(user.id) ? "speaking" : ""}`.trim(), text: user.username }), el("span", { class: "vk-sub", text: status }) ), el("span", { class: "vk-right-trail" }, starButton(true, () => removeFavouriteUser(user.id))) ) ); } for (const dm of groups) root.append(dmRow(dm)); } async function refreshFavourites() { try { const result = await VK.api.get("/api/favourites"); state.favourites = { users: (result.users ?? []).map(user => user.id), channels: result.channels ?? [] }; state.favouriteUsers = result.users ?? []; } catch { // the core may be disconnected } renderRightFavourites(); renderRightVoice(); } function handleLiveEvent(event) { if (event.type === "MESSAGE_CREATE" && event.channelId === state.channelId) { void loadMessages(); } if (event.type === "TRANSCRIPT_SEGMENT") { const list = $("#transcript-list"); if (list) { list.append(transcriptLine(event.segment)); list.scrollTop = list.scrollHeight; $("#transcript-status").textContent = `live · ${list.childElementCount} segments`; } return; } if (event.type === "TRANSCRIPT_TEXT") { const line = document.querySelector(`[data-segment="${event.id}"] .vk-transcript-text`); if (line) line.textContent = event.text; return; } if (event.type === "TRANSCRIPT_ERROR") { toast(event.error, "error"); return; } if (event.type === "VOICE_SPEAKING") { if (event.speaking) beginSpeaking(event.userId); else endSpeaking(event.userId); return; } if (event.type === "VOICE_STATE_UPDATE") { // A mute or a channel change ends speaking immediately: a muted member // never transmits, and the stop event may not arrive. if (!event.channelId || event.mute || event.selfMute) endSpeaking(event.userId); const now = Date.now(); if (now - state.voiceReloadAt > 1000) { state.voiceReloadAt = now; void loadVoice(); } } if (event.type === "CORE_STATUS") { void refreshHealth(); if (event.coreConnected) void refreshAll(); if (!$("#settings-overlay").hidden) void refreshCoreStatus(); } if (event.type === "AUTH_QR" && !$("#settings-overlay").hidden) { void refreshAuth(); } } /* Wiring */ function init() { hydrateIcons(); initTimeZone(); const takeover = $("#takeover"); takeover.checked = state.takeover; $("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover"; takeover.addEventListener("change", () => { state.takeover = takeover.checked; localStorage.setItem("veskslop-takeover", state.takeover ? "1" : "0"); $("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover"; renderComposer(); syncTtsControls(); }); $("#guild-picker").addEventListener("click", () => { const menu = $("#guild-menu"); menu.hidden = !menu.hidden; if (menu.hidden) return; menu.replaceChildren( ...visibleGuildsList().map(guild => el("button", { class: `vk-menu-item ${guild.id === state.guildId ? "active" : ""}`, type: "button", text: guild.name, onclick: () => void selectGuild(guild.id) }) ) ); }); $("#settings-open").addEventListener("click", openSettings); $("#settings-close").addEventListener("click", closeSettings); $("#settings-overlay").addEventListener("click", event => { if (event.target === $("#settings-overlay")) closeSettings(); }); $("#core-start").addEventListener("click", () => void coreAction("/api/core/start", "Start")); $("#core-stop").addEventListener("click", () => void coreAction("/api/core/stop", "Stop")); $("#core-restart").addEventListener("click", () => void coreAction("/api/core/restart", "Restart")); $("#core-start-banner").addEventListener("click", async event => { if (state.daemonDown) { location.reload(); return; } const button = event.currentTarget; button.disabled = true; button.textContent = "Starting…"; try { await VK.api.post("/api/core/start"); toast("Starting the client…", "ok"); } catch (error) { toast(error.message, "error"); } setTimeout(() => void refreshHealth(), 1000); setTimeout(() => void refreshHealth(), 5000); }); $("#auth-start").addEventListener("click", () => void authAction("/api/auth/qr", "QR login started")); $("#auth-cancel").addEventListener("click", () => void authAction("/api/auth/qr/cancel", "Login cancelled")); $("#auth-restart").addEventListener("click", async () => { try { renderCoreStatus(await VK.api.post("/api/core/restart", { forceToken: true })); toast("Restarting with the token file", "ok"); } catch (error) { toast(error.message, "error"); } }); $("#auth-captcha").addEventListener("submit", async event => { event.preventDefault(); const key = $("#auth-captcha-key").value.trim(); if (!key) return; try { await VK.api.post("/api/auth/qr/captcha", { key }); $("#auth-captcha-key").value = ""; toast("Captcha submitted", "ok"); void refreshAuth(); } catch (error) { toast(error.message, "error"); } }); $("#logs-refresh").addEventListener("click", () => void refreshLogs()); $("#console-toggle").addEventListener("click", () => setConsoleOpen(!state.consoleOpen)); for (const tab of $$(".vk-console-tab")) { tab.addEventListener("click", () => setConsoleTab(tab.dataset.consoleTab)); } for (const tab of $$(".vk-tab")) { tab.addEventListener("click", () => setMobileTab(tab.dataset.mobileTab)); } MOBILE_QUERY.addEventListener("change", syncMobileLayout); syncMobileLayout(); $("#raw-events").addEventListener("click", () => { setConsoleTab("events"); if (isMobileLayout()) setMobileTab("activity"); }); $("#transcript-refresh").addEventListener("click", () => void refreshTranscript()); $("#transcript-start").addEventListener("click", () => void setCapture(true)); $("#transcript-stop").addEventListener("click", () => void setCapture(false)); $("#tts-duck").addEventListener("click", async () => { const on = !$("#tts-duck").classList.contains("vk-chip-on"); try { await VK.api.post("/api/preferences", { audio: { duck: on } }); toast(on ? "TTS ducking on" : "TTS ducking off", "ok"); void refreshAudio(); } catch (error) { toast(error.message, "error"); } }); $("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true })); $("#guilds-show-all").addEventListener("click", () => { state.visibleGuilds = null; renderVisibleGuilds(); renderGuildSwitcher(); renderGuildPicker(); }); $("#guilds-hide-all").addEventListener("click", () => { state.visibleGuilds = new Set(); renderVisibleGuilds(); renderGuildSwitcher(); renderGuildPicker(); }); $("#guilds-save").addEventListener("click", () => void saveGuildVisibility()); const ttsInput = $("#tts-input"); ttsInput.value = localStorage.getItem(TTS_DRAFT_KEY) ?? ""; ttsInput.addEventListener("input", () => localStorage.setItem(TTS_DRAFT_KEY, ttsInput.value)); const ttsProvider = $("#tts-provider"); ttsProvider.addEventListener("change", () => { $("#speech-provider").value = ttsProvider.value; void saveSpeech(); }); for (const id of ["tts-speed", "tts-pitch"]) { $(`#${id}`).addEventListener("input", syncSpeechSliderLabels); $(`#${id}`).addEventListener("change", () => void saveSpeech()); } $("#tts-expression").addEventListener("input", () => { const slider = $("#tts-expression"); const engineDefault = state.expressionDefault; const snapped = typeof engineDefault === "number" && Math.abs(Number(slider.value) - engineDefault) <= 2; if (snapped) { slider.value = engineDefault; slider.dataset.dirty = ""; } else { slider.dataset.dirty = "1"; } syncSpeechSliderLabels(); }); $("#tts-expression").addEventListener("change", () => void saveSpeech()); buildProfileSliders(); for (const id of ["tts-highpass", "tts-echo", "tts-word-gap"]) { $(`#${id}`).addEventListener("input", () => { $("#tts-highpass-value").textContent = `${$("#tts-highpass").value}Hz`; $("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off"; $("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`; }); $(`#${id}`).addEventListener("change", () => void saveSpeech()); } $("#tts-words").addEventListener("change", () => void saveSpeech()); $("#tts-vox").addEventListener("change", () => { $("#tts-vox-clean").disabled = !$("#tts-vox").checked; void saveSpeech(); }); $("#tts-preset").addEventListener("change", () => { // Picking the VOX preset turns on VOX prosody as a default. Word mode still wins // for word-by-word delivery, and turning VOX off keeps the preset. if ($("#tts-preset").value === "vox") { $("#tts-vox").checked = true; $("#tts-vox-clean").disabled = false; } void saveSpeech(); }); $("#tts-vox-clean").addEventListener("change", () => void saveSpeech()); for (const id of [ "#tts-echo-delay", "#tts-echo-level", "#tts-crunch", "#tts-crunch-bits", "#tts-crunch-rate", "#tts-stream-words" ]) { $(id).addEventListener("change", () => void saveSpeech()); } $("#profile-open").addEventListener("click", () => { const panel = document.querySelector(".vk-profile-panel"); if (panel) { panel.style.left = ""; panel.style.top = ""; panel.style.transform = ""; } $("#profile-overlay").hidden = false; fillProfileControls(); }); $("#profile-close").addEventListener("click", () => { $("#profile-overlay").hidden = true; }); $("#profile-overlay").addEventListener("click", event => { if (window.__profileDragged) { window.__profileDragged = false; return; } if (event.target === $("#profile-overlay")) $("#profile-overlay").hidden = true; }); (() => { const panel = document.querySelector(".vk-profile-panel"); const handle = $("#profile-drag"); if (!panel || !handle) return; let dragging = false; let startX = 0; let startY = 0; let originX = 0; let originY = 0; handle.addEventListener("mousedown", event => { const rect = panel.getBoundingClientRect(); dragging = true; startX = event.clientX; startY = event.clientY; originX = rect.left; originY = rect.top; panel.style.transform = "none"; panel.style.left = `${originX}px`; panel.style.top = `${originY}px`; event.preventDefault(); }); window.addEventListener("mousemove", event => { if (!dragging) return; if (Math.abs(event.clientX - startX) + Math.abs(event.clientY - startY) > 3) { window.__profileDragged = true; } panel.style.left = `${originX + event.clientX - startX}px`; panel.style.top = `${originY + event.clientY - startY}px`; }); window.addEventListener("mouseup", () => { dragging = false; }); })(); for (const slot of ["1", "2"]) { const input = $(`#tts-test-custom-${slot}`); input.value = localStorage.getItem(`veskslop-test-line-${slot}`) ?? ""; input.addEventListener("input", () => localStorage.setItem(`veskslop-test-line-${slot}`, input.value) ); } $$(".vk-profile-tests button").forEach(button => { button.addEventListener("click", async () => { let text = button.dataset.test ?? ""; if (button.dataset.testCustom) { text = $(`#tts-test-custom-${button.dataset.testCustom}`).value.trim(); if (!text) { toast("Custom line is empty", "error"); return; } } try { const expressionEl = $("#tts-expression"); const expression = expressionEl.dataset.dirty === "1" ? Number(expressionEl.value) : state.speech.expression ?? undefined; await VK.api.post("/api/audio/speak", { text, provider: $("#tts-provider").value, ...effectOptions(), ...(typeof expression === "number" ? { expression } : {}) }); toast(`Playing "${text}"`, "ok"); } catch (error) { toast(error.message, "error"); } }); }); $("#tts-profile-slot").addEventListener("change", () => { const preset = (state.speech.profilePresets ?? [])[Number($("#tts-profile-slot").value)]; $("#tts-profile-name").value = preset?.name ?? ""; }); $("#tts-profile-save").addEventListener("click", async () => { const slot = Number($("#tts-profile-slot").value); const presets = (state.speech.profilePresets ?? [null, null, null, null, null]).slice(0, 5); const name = $("#tts-profile-name").value.trim() || `Profile ${slot + 1}`; presets[slot] = { name, ...profileFromControls(), ...(typeof state.speech.expression === "number" ? { expression: state.speech.expression } : {}) }; await saveSpeech({ profilePresets: presets }); toast(`Saved "${name}" to slot ${slot + 1}`, "ok"); }); $("#tts-profile-load").addEventListener("click", async () => { const slot = Number($("#tts-profile-slot").value); const preset = (state.speech.profilePresets ?? [])[slot]; if (!preset) { toast(`Slot ${slot + 1} is empty`, "error"); return; } for (const [key] of PROFILE_SPEC) { const slider = $(`#tts-profile-${key}`); if (!slider || typeof preset[key] !== "number") continue; slider.value = preset[key]; slider.dataset.dirty = "1"; } if (typeof preset.expression === "number") { $("#tts-expression").value = preset.expression; $("#tts-expression").dataset.dirty = "1"; } fillProfileControls(); syncSpeechSliderLabels(); await saveSpeech(); toast(`Loaded "${preset.name ?? `Profile ${slot + 1}`}"`, "ok"); }); $("#tts-speed-presets").addEventListener("click", event => { const button = event.target.closest("button[data-speed]"); if (!button) return; $("#tts-speed").value = button.dataset.speed; syncSpeechSliderLabels(); void saveSpeech(); }); syncSpeechSliderLabels(); $("#speech-provider").addEventListener("change", () => void saveSpeech()); $("#speech-warm").addEventListener("click", () => void warmEngine()); $("#speech-language").addEventListener("change", () => { $("#speech-voice").value = ""; void saveSpeech(); }); $("#speech-voice").addEventListener("change", () => void saveSpeech()); $("#tts-voice").addEventListener("change", () => { $("#speech-voice").value = $("#tts-voice").value; void saveSpeech(); }); $("#scope-guild").addEventListener("click", () => void applyScope("guild")); $("#scope-dm").addEventListener("click", () => void applyScope("dm")); $("#tts-close").addEventListener("click", () => { $("#tts-panel").hidden = true; }); $("#tts-speak").addEventListener("click", () => void speakText()); $("#tts-stop").addEventListener("click", async () => { try { await VK.api.post("/api/audio/stop"); $("#tts-status").textContent = "stopped"; } catch (error) { toast(error.message, "error"); } }); $("#audio-refresh").addEventListener("click", () => void refreshAudio()); $("#audio-connect").addEventListener("click", async () => { try { const result = await VK.api.post("/api/audio/connect"); toast(result.message ?? (result.connected ? "Devices connected" : "Could not connect"), result.connected ? "ok" : "error"); void refreshAudio(); } catch (error) { toast(error.message, "error"); } }); $("#client-audio").addEventListener("click", toggleClientAudio); $("#browser-grant").addEventListener("click", () => void grantBrowserAudio()); $("#browser-output").addEventListener("change", event => { state.clientAudio.sinkId = event.currentTarget.value; localStorage.setItem("veskslop-browser-output", state.clientAudio.sinkId); applyBrowserSink(); }); $("#audio-capture").addEventListener("click", async () => { try { const start = !state.audioRecording; await VK.api.post("/api/audio/capture", { start }); toast(start ? "Capture started" : "Capture stopped", "ok"); void refreshAudio(); } catch (error) { toast(error.message, "error"); } }); document.addEventListener("keydown", event => { if (event.key !== "Escape") return; if (!$("#settings-overlay").hidden) closeSettings(); else if (state.consoleOpen) setConsoleOpen(false); }); VK.bus.on("event", event => { if (event.type === "hello") { applyHello(event); return; } addEvent(event); handleLiveEvent(event); }); VK.connectEvents(); setConsoleTab("events"); setConsoleOpen(false); syncConsoleCount(); renderRightFavourites(); void refreshAll(); void refreshHealth(); syncTtsControls(); setInterval(refreshHealth, 5000); setInterval(() => { if (!$("#tts-panel").hidden) void refreshAudio(); }, 3000); setInterval(() => { if (!$("#settings-overlay").hidden) { if (!$("#auth-qr").hidden || $("#auth-captcha").hidden === false) void refreshAuth(); } }, 3000); } init();