3051 lines
100 KiB
JavaScript
3051 lines
100 KiB
JavaScript
"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;
|
|
}
|
|
|
|
// The daemon plays asynchronously; keep the panel honest about queued vs speaking vs done.
|
|
let speechTracker = 0;
|
|
|
|
async function trackSpeech() {
|
|
const token = ++speechTracker;
|
|
|
|
for (let attempt = 0; attempt < 1200; attempt++) {
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
if (token !== speechTracker) return;
|
|
|
|
try {
|
|
const status = await VK.api.get("/api/audio");
|
|
if (token !== speechTracker) return;
|
|
|
|
const label = $("#tts-status");
|
|
if (status?.playing === true) {
|
|
label.textContent = "speaking\u2026";
|
|
continue;
|
|
}
|
|
|
|
const queue = Number(status?.queue ?? 0);
|
|
if (queue > 0) {
|
|
label.textContent = `queued (${queue})`;
|
|
continue;
|
|
}
|
|
|
|
label.textContent = "done";
|
|
setTimeout(() => {
|
|
if (token === speechTracker && $("#tts-status").textContent === "done") {
|
|
$("#tts-status").textContent = "idle";
|
|
}
|
|
}, 4000);
|
|
return;
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
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-vox-clean").checked = state.speech.voxClean === 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 and the Half-Life profile are part of the `vox`/`hev` voices now.
|
|
vox: false,
|
|
preset: null,
|
|
voxClean: $("#tts-vox-clean").checked,
|
|
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;
|
|
|
|
// The Half-Life voices carry VOX prosody; their clean toggle lives in the profile area.
|
|
const voxOptions = $("#tts-vox-options");
|
|
if (voxOptions) voxOptions.hidden = selected !== "vox";
|
|
|
|
$("#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();
|
|
void trackSpeech();
|
|
} 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-clean").addEventListener("change", () => 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();
|