2026-05-13 00:32:27 +07:00
<!DOCTYPE html>
< html lang = "en" >
< head >
2026-05-13 20:52:37 +07:00
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Discord Moderation Watcher< / title >
< link rel = "preconnect" href = "https://fonts.googleapis.com" >
< link rel = "preconnect" href = "https://fonts.gstatic.com" crossorigin >
< link href = "https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@500;700;800&display=swap" rel = "stylesheet" >
< style >
:root {
--bg: #080a0f;
--panel: rgba(18, 22, 32, 0.86);
--panel-strong: #121720;
--line: rgba(255, 255, 255, 0.12);
--text: #edf4ff;
--muted: #91a0b6;
--faint: #536176;
--cyan: #00e5ff;
--green: #39ff88;
--yellow: #ffe45e;
--red: #ff4f6d;
--blue: #6275ff;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.46);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: Manrope, sans-serif;
background:
radial-gradient(circle at 12% 8%, rgba(0, 229, 255, 0.18), transparent 32rem),
radial-gradient(circle at 88% 0%, rgba(98, 117, 255, 0.2), transparent 28rem),
linear-gradient(145deg, #05060a, var(--bg));
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 84%);
}
button, select { font: inherit; }
.shell {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 44px;
}
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
align-items: stretch;
margin-bottom: 18px;
}
.brand-card,
.status-card,
.tab-panel,
.content-card {
border: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.brand-card {
position: relative;
padding: 28px;
border-radius: 28px;
overflow: hidden;
min-height: 190px;
}
.brand-card::after {
content: "WATCHER";
position: absolute;
right: -14px;
bottom: -20px;
font-family: "Archivo Black", sans-serif;
font-size: clamp(58px, 9vw, 132px);
letter-spacing: -0.08em;
color: rgba(255,255,255,0.035);
line-height: 0.78;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
color: var(--cyan);
font: 700 12px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.18em;
}
.pulse {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--green);
box-shadow: 0 0 20px var(--green);
}
h1 {
margin: 0;
max-width: 840px;
font-family: "Archivo Black", sans-serif;
font-size: clamp(40px, 6vw, 82px);
line-height: 0.88;
letter-spacing: -0.06em;
text-transform: uppercase;
}
.subtitle {
margin: 18px 0 0;
max-width: 720px;
color: var(--muted);
font-size: 15px;
line-height: 1.7;
}
.status-card {
border-radius: 28px;
padding: 24px;
display: grid;
gap: 14px;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.status-row:last-child { border-bottom: 0; }
.status-label {
color: var(--muted);
font: 700 12px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.status-value {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font: 700 13px/1 "JetBrains Mono", monospace;
}
.dot {
width: 9px;
height: 9px;
border-radius: 99px;
background: var(--faint);
}
.dot.on { background: var(--green); box-shadow: 0 0 16px var(--green); }
.dot.warn { background: var(--yellow); box-shadow: 0 0 16px var(--yellow); }
.tab-panel {
position: sticky;
top: 14px;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border-radius: 24px;
margin-bottom: 18px;
}
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tab-btn {
border: 1px solid transparent;
color: var(--muted);
background: transparent;
border-radius: 16px;
padding: 12px 16px;
cursor: pointer;
font-weight: 800;
transition: 160ms ease;
}
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.tab-btn.active {
color: #061014;
background: linear-gradient(135deg, var(--cyan), var(--green));
box-shadow: 0 12px 28px rgba(0,229,255,0.18);
}
.filter-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 13px;
}
select {
min-width: 240px;
color: var(--text);
background: rgba(5,8,14,0.78);
border: 1px solid var(--line);
border-radius: 14px;
padding: 11px 14px;
outline: none;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.voice-layout {
display: grid;
grid-template-columns: 380px 1fr;
gap: 18px;
}
.content-card {
border-radius: 28px;
padding: 22px;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.card-title h2 {
margin: 0;
font-family: "Archivo Black", sans-serif;
font-size: 26px;
letter-spacing: -0.04em;
text-transform: uppercase;
}
.mini {
color: var(--faint);
font: 700 11px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.field-group { display: grid; gap: 8px; margin-bottom: 14px; }
.field-group label { color: var(--muted); font-size: 13px; font-weight: 800; }
.button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.btn {
border: 0;
border-radius: 16px;
padding: 13px 16px;
color: #061014;
cursor: pointer;
font-weight: 900;
transition: transform 140ms ease, filter 140ms ease;
}
.btn:hover { transform: translateY(-1px); filter: brightness(1.08); }
.btn-primary { background: linear-gradient(135deg, var(--cyan), var(--blue)); color: white; }
.btn-success { background: linear-gradient(135deg, var(--green), var(--cyan)); }
.btn-danger { background: linear-gradient(135deg, var(--red), #ff9a6b); color: white; }
.voice-status { color: var(--muted); font-size: 13px; margin-top: 12px; min-height: 20px; }
.visualizer {
display: flex;
align-items: flex-end;
gap: 5px;
height: 130px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(0,0,0,0.22);
overflow: hidden;
}
.bar {
flex: 1;
min-width: 5px;
border-radius: 999px;
height: 3px;
background: linear-gradient(to top, var(--blue), var(--cyan), var(--green));
box-shadow: 0 0 18px rgba(0,229,255,0.16);
}
.participants {
display: grid;
gap: 10px;
}
.user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.035);
}
.user-item.speaking { border-color: rgba(57,255,136,0.55); background: rgba(57,255,136,0.08); }
.user-item img { width: 34px; height: 34px; border-radius: 999px; }
.feed {
display: grid;
gap: 12px;
}
.event-card {
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(255,255,255,0.035);
}
.event-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.author {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 999px;
background: linear-gradient(135deg, var(--blue), var(--cyan));
flex: 0 0 auto;
}
.avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
.name { font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.time { color: var(--faint); font: 600 11px/1 "JetBrains Mono", monospace; white-space: nowrap; }
.message-text { color: #dbe6f7; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
2026-05-13 21:04:45 +07:00
.sticker-strip, .attachment-strip { display: flex; gap: 10px; flex-wrap: wrap; }
2026-05-13 20:52:37 +07:00
.sticker-img { width: 96px; height: 96px; object-fit: contain; border-radius: 16px; background: rgba(0,0,0,0.22); border: 1px solid var(--line); padding: 8px; }
2026-05-13 21:04:45 +07:00
.attachment-chip { color: var(--cyan); text-decoration: none; border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; font: 700 12px/1 "JetBrains Mono", monospace; background: rgba(0,229,255,0.06); }
.embed-card { border-left: 4px solid var(--blue); border-radius: 16px; padding: 12px; background: rgba(98,117,255,0.08); display: grid; gap: 8px; }
.embed-title { font-weight: 900; color: var(--text); }
.embed-description { color: var(--muted); line-height: 1.5; white-space: pre-wrap; }
.embed-image { max-width: 360px; width: 100%; border-radius: 14px; border: 1px solid var(--line); }
2026-05-13 20:52:37 +07:00
.badges { display: flex; gap: 8px; flex-wrap: wrap; }
.badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 9px;
color: var(--muted);
font: 700 10px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
}
.badge.edit { color: var(--yellow); border-color: rgba(255,228,94,0.36); }
.badge.delete { color: var(--red); border-color: rgba(255,79,109,0.42); }
.filename { font-size: 13px; font-weight: 900; word-break: break-word; }
.link { color: var(--cyan); text-decoration: none; font-weight: 900; }
.link:hover { text-decoration: underline; }
.empty {
padding: 34px;
text-align: center;
color: var(--faint);
border: 1px dashed var(--line);
border-radius: 22px;
}
.error {
display: none;
margin-bottom: 14px;
padding: 12px 14px;
color: #ffd8df;
border: 1px solid rgba(255,79,109,0.5);
border-radius: 16px;
background: rgba(255,79,109,0.12);
}
@media (max-width: 980px) {
.hero, .voice-layout { grid-template-columns: 1fr; }
.tab-panel { align-items: stretch; flex-direction: column; }
.filter-row { align-items: stretch; flex-direction: column; }
select { width: 100%; min-width: 0; }
}
< / style >
2026-05-13 00:32:27 +07:00
< / head >
< body >
2026-05-13 20:52:37 +07:00
< main class = "shell" >
< section class = "hero" >
< div class = "brand-card" >
< div class = "eyebrow" > < span class = "pulse" > < / span > Discord moderation command center< / div >
2026-05-13 21:28:45 +07:00
< h1 > Voice. Text. One Watch Floor.< / h1 >
< p class = "subtitle" > Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.< / p >
2026-05-13 20:52:37 +07:00
< / div >
< div class = "status-card" >
< div class = "status-row" > < span class = "status-label" > WebSocket< / span > < span class = "status-value" > < span id = "wsDot" class = "dot" > < / span > < span id = "wsStatusText" > Connecting< / span > < / span > < / div >
< div class = "status-row" > < span class = "status-label" > Voice Link< / span > < span id = "voiceStatusText" class = "status-value" > Not connected< / span > < / div >
< div class = "status-row" > < span class = "status-label" > Active Tab< / span > < span id = "activeTabLabel" class = "status-value" > Voice< / span > < / div >
< / div >
< / section >
< nav class = "tab-panel" >
< div class = "tabs" >
< button class = "tab-btn active" data-tab = "voice" > Voice< / button >
< button class = "tab-btn" data-tab = "text" > Text< / button >
< / div >
< div class = "filter-row" >
< span > Channel / Thread< / span >
< select id = "channelFilter" > < option value = "" > Select channel< / option > < / select >
< / div >
< / nav >
< div id = "errorBox" class = "error" > < / div >
< section id = "voice" class = "tab-content active" >
< div class = "voice-layout" >
< div class = "content-card" >
< div class = "card-title" > < h2 > Voice Control< / h2 > < span class = "mini" > bridge< / span > < / div >
< div class = "field-group" >
< label for = "guildSelect" > Guild< / label >
< select id = "guildSelect" > < / select >
< / div >
< div class = "field-group" >
< label for = "channelSelect" > Voice Channel< / label >
< select id = "channelSelect" > < / select >
< / div >
< div class = "button-row" >
< button id = "joinVoiceBtn" class = "btn btn-success" > Join< / button >
< button id = "disconnectVoiceBtn" class = "btn btn-danger" > Disconnect< / button >
< / div >
< div class = "voice-status" id = "voiceStatusNote" > Idle< / div >
2026-05-13 18:23:20 +07:00
< / div >
2026-05-13 20:52:37 +07:00
< div class = "content-card" >
< div class = "card-title" > < h2 > Live Audio< / h2 > < span class = "mini" id = "listenStatus" > speaker off< / span > < / div >
< div style = "display:grid; gap:12px; grid-template-columns: 1fr 1fr; margin-bottom:14px;" >
2026-05-13 02:30:09 +07:00
< button id = "toggleBtn" class = "btn btn-primary" > Start Transmitting< / button >
< button id = "listenBtn" class = "btn btn-success" > Join Listen Channel< / button >
2026-05-13 20:52:37 +07:00
< / div >
< div class = "visualizer" id = "visualizer" > < / div >
2026-05-13 00:32:27 +07:00
< / div >
2026-05-13 20:52:37 +07:00
< / div >
< div class = "content-card" style = "margin-top:18px;" >
< div class = "card-title" > < h2 > Participants< / h2 > < span class = "mini" > speaking now< / span > < / div >
< div id = "userList" class = "participants" > < / div >
< / div >
< / section >
< section id = "text" class = "tab-content" >
< div class = "content-card" >
< div class = "card-title" > < h2 > Text Watch< / h2 > < span class = "mini" > create / edit / delete< / span > < / div >
< div id = "textList" class = "feed" > < / div >
< / div >
< / section >
< / main >
< script >
const state = {
socket: null,
activeTab: 'voice',
selectedChannel: '',
text: [],
isStreaming: false,
isListening: false,
audioContextTransmit: null,
audioContextListen: null,
processor: null,
nextStartTime: 0,
noiseGateHold: 0,
};
const SAMPLE_RATE = 24000;
const NOISE_GATE_THRESHOLD = 0.01;
const NOISE_GATE_HOLD_FRAMES = 3;
const el = {
wsDot: document.getElementById('wsDot'),
wsStatusText: document.getElementById('wsStatusText'),
activeTabLabel: document.getElementById('activeTabLabel'),
errorBox: document.getElementById('errorBox'),
guildSelect: document.getElementById('guildSelect'),
channelSelect: document.getElementById('channelSelect'),
channelFilter: document.getElementById('channelFilter'),
joinVoiceBtn: document.getElementById('joinVoiceBtn'),
disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'),
voiceStatusText: document.getElementById('voiceStatusText'),
voiceStatusNote: document.getElementById('voiceStatusNote'),
toggleBtn: document.getElementById('toggleBtn'),
listenBtn: document.getElementById('listenBtn'),
listenStatus: document.getElementById('listenStatus'),
visualizer: document.getElementById('visualizer'),
userList: document.getElementById('userList'),
textList: document.getElementById('textList'),
};
for (let i = 0; i < 32 ; i + + ) {
const bar = document.createElement('div');
bar.className = 'bar';
el.visualizer.appendChild(bar);
}
const bars = [...document.querySelectorAll('.bar')];
async function apiRequest(url, options = {}) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(error.message || response.statusText);
}
return response.json();
}
function showError(message) {
el.errorBox.textContent = message;
el.errorBox.style.display = 'block';
setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500);
}
function renderOptions(select, items, placeholder) {
select.replaceChildren();
const first = document.createElement('option');
first.value = '';
first.textContent = placeholder;
select.appendChild(first);
for (const item of items) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
}
async function loadGuilds() {
const guilds = await apiRequest('/api/guilds');
renderOptions(el.guildSelect, guilds, 'Select guild');
if (guilds.length > 0) {
el.guildSelect.value = guilds[0].id;
await loadChannels(guilds[0].id);
}
}
async function loadChannels(guildId) {
const [voiceChannels, watchChannels] = await Promise.all([
apiRequest(`/api/guilds/${guildId}/voice-channels`),
apiRequest(`/api/guilds/${guildId}/channels`),
]);
renderOptions(el.channelSelect, voiceChannels, 'Select voice channel');
2026-05-13 21:22:05 +07:00
renderOptions(el.channelFilter, watchChannels, 'Select channel');
apiRequest(`/api/guilds/${guildId}/threads`)
.then((threads) => appendOptions(el.channelFilter, threads))
.catch((error) => showError(`Thread discovery failed: ${error.message}`));
}
function appendOptions(select, items) {
const existing = new Set([...select.options].map((option) => option.value));
for (const item of items) {
if (existing.has(item.id)) continue;
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
2026-05-13 20:52:37 +07:00
}
async function refreshStatus() {
try {
const status = await apiRequest('/api/status');
el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected';
el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle';
} catch (error) {
showError(error.message);
}
}
async function connectVoice() {
const guildId = el.guildSelect.value;
const channelId = el.channelSelect.value;
if (!guildId || !channelId) return showError('Select guild and voice channel first');
const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) });
el.voiceStatusText.textContent = status.activeChannelName || 'Connected';
el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`;
}
async function disconnectVoice() {
await apiRequest('/api/disconnect', { method: 'POST' });
await refreshStatus();
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
state.socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
state.socket.binaryType = 'arraybuffer';
state.socket.onopen = () => {
el.wsDot.classList.add('on');
el.wsStatusText.textContent = 'Connected';
};
state.socket.onclose = () => {
el.wsDot.classList.remove('on');
el.wsStatusText.textContent = 'Reconnecting';
setTimeout(connectWebSocket, 2500);
};
state.socket.onerror = () => {
el.wsDot.classList.remove('on');
el.wsDot.classList.add('warn');
el.wsStatusText.textContent = 'Socket error';
};
state.socket.onmessage = (event) => {
if (typeof event.data === 'string') {
handleJsonEvent(event.data);
return;
}
if (state.isListening) playPcm(event.data);
};
}
function handleJsonEvent(raw) {
const message = JSON.parse(raw);
if (message.type === 'user_state') return renderUsers(message.users || []);
if (message.type === 'message_created') {
state.text.unshift(message.data);
renderText();
}
if (message.type === 'message_updated') {
const item = state.text.find((entry) => entry.id === message.data.id);
if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' });
renderText();
}
if (message.type === 'message_deleted') {
const item = state.text.find((entry) => entry.id === message.data.id);
if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' });
renderText();
}
2026-05-13 21:28:45 +07:00
if (message.type === 'attachment_uploaded') fetchText();
2026-05-13 20:52:37 +07:00
}
function renderUsers(users) {
el.userList.replaceChildren();
if (users.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No active speakers';
el.userList.appendChild(empty);
return;
}
for (const user of users) {
const row = document.createElement('div');
row.className = `user-item${user.speaking ? ' speaking' : ''}`;
const img = document.createElement('img');
img.src = user.avatar || '';
img.alt = '';
const name = document.createElement('span');
name.textContent = user.username;
row.append(img, name);
el.userList.appendChild(row);
}
}
async function fetchText() {
if (!state.selectedChannel) return renderText();
const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedChannel)}&type=text&limit=80`);
state.text = result.data || [];
renderText();
}
function parseMetadata(value) {
if (!value) return {};
try { return JSON.parse(value); } catch { return {}; }
}
function renderText() {
el.textList.replaceChildren();
if (!state.selectedChannel) return appendEmpty(el.textList, 'Select channel to view text captures');
if (state.text.length === 0) return appendEmpty(el.textList, 'No text captures yet');
for (const msg of state.text) {
const metadata = parseMetadata(msg.metadata);
const card = document.createElement('article');
card.className = 'event-card';
const head = document.createElement('div');
head.className = 'event-head';
const author = document.createElement('div');
author.className = 'author';
const avatar = document.createElement('div');
avatar.className = 'avatar';
if (msg.avatar_url) {
const img = document.createElement('img');
img.src = msg.avatar_url;
img.alt = '';
avatar.appendChild(img);
}
const name = document.createElement('div');
name.className = 'name';
name.textContent = msg.username || msg.user_id;
author.append(avatar, name);
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date(msg.created_at).toLocaleString();
head.append(author, time);
const text = document.createElement('div');
text.className = 'message-text';
text.textContent = msg.edited_content || msg.content || '(empty message)';
2026-05-13 21:04:45 +07:00
const stickers = renderStickers(metadata.stickers || []);
const embeds = renderEmbeds(metadata.embeds || []);
const attachments = renderAttachments(metadata.attachments || []);
2026-05-13 20:52:37 +07:00
const badges = document.createElement('div');
badges.className = 'badges';
2026-05-13 21:04:45 +07:00
if (metadata.reference?.messageId) appendBadge(badges, 'reply', '');
if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', '');
2026-05-13 20:52:37 +07:00
if (msg.edited_at) appendBadge(badges, 'edited', 'edit');
if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete');
card.append(head, text);
if (stickers.childElementCount > 0) card.appendChild(stickers);
2026-05-13 21:04:45 +07:00
if (embeds.childElementCount > 0) card.appendChild(embeds);
if (attachments.childElementCount > 0) card.appendChild(attachments);
2026-05-13 20:52:37 +07:00
card.appendChild(badges);
el.textList.appendChild(card);
}
}
2026-05-13 21:04:45 +07:00
function renderStickers(stickers) {
const wrap = document.createElement('div');
wrap.className = 'sticker-strip';
for (const sticker of stickers) {
const img = document.createElement('img');
img.className = 'sticker-img';
img.src = sticker.url;
img.alt = sticker.name;
wrap.appendChild(img);
}
return wrap;
}
function renderEmbeds(embeds) {
const wrap = document.createElement('div');
wrap.className = 'feed';
for (const embed of embeds) {
const card = document.createElement('div');
card.className = 'embed-card';
if (embed.title) {
const title = document.createElement(embed.url ? 'a' : 'div');
title.className = 'embed-title';
title.textContent = embed.title;
if (embed.url) {
title.href = embed.url;
title.target = '_blank';
title.rel = 'noreferrer';
}
card.appendChild(title);
}
if (embed.description) {
const desc = document.createElement('div');
desc.className = 'embed-description';
desc.textContent = embed.description;
card.appendChild(desc);
}
for (const field of embed.fields || []) {
const fieldNode = document.createElement('div');
fieldNode.className = 'embed-description';
fieldNode.textContent = `${field.name}: ${field.value}`;
card.appendChild(fieldNode);
}
if (embed.image || embed.thumbnail) {
const img = document.createElement('img');
img.className = 'embed-image';
img.src = embed.image || embed.thumbnail;
img.alt = embed.title || 'embed image';
card.appendChild(img);
}
wrap.appendChild(card);
}
return wrap;
}
function renderAttachments(attachments) {
const wrap = document.createElement('div');
wrap.className = 'attachment-strip';
for (const attachment of attachments) {
const link = document.createElement('a');
link.className = 'attachment-chip';
link.href = attachment.url;
link.target = '_blank';
link.rel = 'noreferrer';
link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`;
wrap.appendChild(link);
}
return wrap;
}
2026-05-13 20:52:37 +07:00
function appendBadge(parent, label, className) {
const badge = document.createElement('span');
badge.className = `badge ${className}`;
badge.textContent = label;
parent.appendChild(badge);
}
function appendEmpty(parent, message) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = message;
parent.appendChild(empty);
}
async function startStreaming() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
state.audioContextTransmit = new AudioContext({ sampleRate: SAMPLE_RATE });
const source = state.audioContextTransmit.createMediaStreamSource(stream);
state.processor = state.audioContextTransmit.createScriptProcessor(2048, 1, 1);
source.connect(state.processor);
state.processor.connect(state.audioContextTransmit.destination);
state.processor.onaudioprocess = (event) => {
if (!state.isStreaming || state.socket?.readyState !== WebSocket.OPEN) return;
const input = event.inputBuffer.getChannelData(0);
let sum = 0;
for (let i = 0; i < input.length ; i + + ) sum + = input [ i ] * input [ i ] ;
const rms = Math.sqrt(sum / input.length);
if (rms < NOISE_GATE_THRESHOLD & & state . noiseGateHold < = 0 ) return ;
state.noiseGateHold = rms >= NOISE_GATE_THRESHOLD ? NOISE_GATE_HOLD_FRAMES : state.noiseGateHold - 1;
const pcm = new Int16Array(input.length);
for (let i = 0; i < input.length ; i + + ) pcm [ i ] = Math . max ( -1 , Math . min ( 1 , input [ i ] ) ) * 32767 ;
state.socket.send(pcm.buffer);
updateVisualizer(rms);
2026-05-13 00:32:27 +07:00
};
2026-05-13 20:52:37 +07:00
state.isStreaming = true;
el.toggleBtn.textContent = 'Stop Transmitting';
} catch (error) {
showError(`Microphone error: ${error.message}`);
}
}
function stopStreaming() {
state.isStreaming = false;
state.processor?.disconnect();
state.audioContextTransmit?.close();
state.processor = null;
state.audioContextTransmit = null;
el.toggleBtn.textContent = 'Start Transmitting';
updateVisualizer(0);
}
function toggleListen() {
state.isListening = !state.isListening;
if (state.isListening) {
state.audioContextListen = new AudioContext({ sampleRate: 24000 });
state.nextStartTime = state.audioContextListen.currentTime;
el.listenBtn.textContent = 'Leave Listen Channel';
el.listenStatus.textContent = 'speaker on';
} else {
state.audioContextListen?.close();
state.audioContextListen = null;
el.listenBtn.textContent = 'Join Listen Channel';
el.listenStatus.textContent = 'speaker off';
}
}
function playPcm(arrayBuffer) {
if (!state.audioContextListen) return;
const bytes = new Uint8Array(arrayBuffer);
if (bytes.byteLength < = 4) return;
const pcm = new Int16Array(bytes.buffer, bytes.byteOffset + 4, (bytes.byteLength - 4) / 2);
const audioBuffer = state.audioContextListen.createBuffer(1, pcm.length, 24000);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < pcm.length ; i + + ) channel [ i ] = pcm [ i ] / 32768 ;
const source = state.audioContextListen.createBufferSource();
source.buffer = audioBuffer;
source.connect(state.audioContextListen.destination);
const startAt = Math.max(state.nextStartTime, state.audioContextListen.currentTime);
source.start(startAt);
state.nextStartTime = startAt + audioBuffer.duration;
}
function updateVisualizer(level) {
bars.forEach((bar, index) => {
const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65;
bar.style.height = `${Math.max(3, level * 190 * wave)}px`;
});
}
document.querySelectorAll('.tab-btn').forEach((button) => {
button.addEventListener('click', async () => {
document.querySelectorAll('.tab-btn').forEach((item) => item.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach((item) => item.classList.remove('active'));
button.classList.add('active');
state.activeTab = button.dataset.tab;
document.getElementById(state.activeTab).classList.add('active');
el.activeTabLabel.textContent = button.textContent;
if (state.activeTab === 'text') await fetchText();
});
});
el.guildSelect.addEventListener('change', () => loadChannels(el.guildSelect.value).catch((error) => showError(error.message)));
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
el.toggleBtn.addEventListener('click', () => state.isStreaming ? stopStreaming() : startStreaming());
el.listenBtn.addEventListener('click', toggleListen);
el.channelFilter.addEventListener('change', async () => {
state.selectedChannel = el.channelFilter.value;
2026-05-13 21:28:45 +07:00
await fetchText().catch((error) => showError(error.message));
2026-05-13 20:52:37 +07:00
});
connectWebSocket();
loadGuilds().then(refreshStatus).catch((error) => showError(error.message));
setInterval(() => {
if (state.activeTab === 'text') fetchText().catch(() => {});
}, 7000);
< / script >
2026-05-13 00:32:27 +07:00
< / body >
< / html >