feat: implement WebCodecs Opus decode for browser listen
- Add broadcastOpusToWeb to PcmBroadcaster interface for raw Opus packets - Server broadcasts Opus frames with mode byte (1) + user hash + packet data - Browser detects packet mode: mode=1 for Opus, mode=0 for legacy PCM - Implement WebCodecs AudioDecoder for Opus decoding in browser - Keep existing PCM playback as fallback for compatibility - Show error if WebCodecs unsupported - Fixes listen feature under Bun where native Opus decode unavailable
This commit is contained in:
60
docs/superpowers/plans/2026-05-13-react-ssr-dashboard.md
Normal file
60
docs/superpowers/plans/2026-05-13-react-ssr-dashboard.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# React SSR Dashboard Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace static client-rendered homepage with React server-side rendering while keeping live WebSocket/voice behavior as progressive enhancement.
|
||||||
|
|
||||||
|
**Architecture:** Express `GET /` builds dashboard data, renders React component to HTML with `react-dom/server`, injects bootstrap JSON for client script. CSS/JS move to static assets; React owns initial markup only, lightweight browser JS handles tab switching, voice bridge, WebSocket updates, and async thread discovery.
|
||||||
|
|
||||||
|
**Tech Stack:** React, ReactDOM server, Bun, Express, TypeScript, vanilla browser JS for progressive enhancement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add React dependencies
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `bun.lockb`
|
||||||
|
|
||||||
|
- [ ] Run `bun add react react-dom`.
|
||||||
|
- [ ] Run `bun add -d @types/react @types/react-dom`.
|
||||||
|
- [ ] Verify `bun run typecheck`.
|
||||||
|
|
||||||
|
### Task 2: Extract dashboard assets
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `public/dashboard.css`
|
||||||
|
- Create: `public/dashboard.js`
|
||||||
|
- Modify: `public/index.html`
|
||||||
|
|
||||||
|
- [ ] Move current `<style>` content to `dashboard.css`.
|
||||||
|
- [ ] Move current `<script>` content to `dashboard.js`.
|
||||||
|
- [ ] Keep client behavior independent from static HTML.
|
||||||
|
|
||||||
|
### Task 3: Create React SSR renderer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/web/dashboardPage.tsx`
|
||||||
|
|
||||||
|
- [ ] Create `DashboardPage` React component accepting: guilds, voiceChannels, watchChannels, selectedChannel, messages, status.
|
||||||
|
- [ ] Render same Voice/Text layout as current homepage.
|
||||||
|
- [ ] Render message cards server-side from DB metadata.
|
||||||
|
- [ ] Export `renderDashboardPage(props)` returning full HTML with CSS/JS links and bootstrap JSON.
|
||||||
|
|
||||||
|
### Task 4: Wire Express SSR route
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/webserver.ts`
|
||||||
|
|
||||||
|
- [ ] Add `GET /` before static middleware fallback or before static index handling.
|
||||||
|
- [ ] Build props from `voiceController` and `getMessagesByChannel`.
|
||||||
|
- [ ] Respect query `?guild=<id>&channel=<id>`.
|
||||||
|
- [ ] Render HTML with `renderDashboardPage`.
|
||||||
|
|
||||||
|
### Task 5: Verify
|
||||||
|
|
||||||
|
**Files:** all touched files
|
||||||
|
|
||||||
|
- [ ] Run `bun run typecheck`.
|
||||||
|
- [ ] Run `bun run test`.
|
||||||
|
- [ ] Run short SSR import smoke if possible.
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
"pino-http": "^11.0.0",
|
"pino-http": "^11.0.0",
|
||||||
"prism-media": "2.0.0-alpha.0",
|
"prism-media": "2.0.0-alpha.0",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
"sodium-native": "^4.3.2",
|
"sodium-native": "^4.3.2",
|
||||||
"ws": "^8.20.1",
|
"ws": "^8.20.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -39,6 +41,8 @@
|
|||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
"pino-pretty": "^10.3.1",
|
"pino-pretty": "^10.3.1",
|
||||||
|
|||||||
417
public/dashboard.css
Normal file
417
public/dashboard.css
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
: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; }
|
||||||
|
|
||||||
|
.sticker-strip, .attachment-strip { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.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; }
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
555
public/dashboard.js
Normal file
555
public/dashboard.js
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
const bootstrapData = JSON.parse(document.getElementById('__DASHBOARD_DATA__')?.textContent || '{}');
|
||||||
|
const state = {
|
||||||
|
socket: null,
|
||||||
|
activeTab: 'voice',
|
||||||
|
selectedChannel: bootstrapData.selectedChannelId || '',
|
||||||
|
text: bootstrapData.messages || [],
|
||||||
|
isStreaming: false,
|
||||||
|
isListening: false,
|
||||||
|
audioContextTransmit: null,
|
||||||
|
audioContextListen: null,
|
||||||
|
processor: null,
|
||||||
|
nextStartTime: 0,
|
||||||
|
noiseGateHold: 0,
|
||||||
|
opusDecoder: null,
|
||||||
|
opusDecoderReady: false,
|
||||||
|
opusDecodeQueue: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = bootstrapData.guilds || await apiRequest('/api/guilds');
|
||||||
|
renderOptions(el.guildSelect, guilds, 'Select guild');
|
||||||
|
const guildId = bootstrapData.selectedGuildId || guilds[0]?.id || '';
|
||||||
|
if (guildId) {
|
||||||
|
el.guildSelect.value = guildId;
|
||||||
|
await loadChannels(guildId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChannels(guildId) {
|
||||||
|
const useBootstrap = guildId === bootstrapData.selectedGuildId;
|
||||||
|
const [voiceChannels, watchChannels] = await Promise.all([
|
||||||
|
useBootstrap && bootstrapData.voiceChannels ? bootstrapData.voiceChannels : apiRequest(`/api/guilds/${guildId}/voice-channels`),
|
||||||
|
useBootstrap && bootstrapData.watchChannels ? bootstrapData.watchChannels : apiRequest(`/api/guilds/${guildId}/channels`),
|
||||||
|
]);
|
||||||
|
renderOptions(el.channelSelect, voiceChannels, 'Select voice channel');
|
||||||
|
renderOptions(el.channelFilter, watchChannels, 'Select channel');
|
||||||
|
el.channelFilter.value = state.selectedChannel;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) return;
|
||||||
|
const bytes = new Uint8Array(event.data);
|
||||||
|
if (bytes.byteLength < 5) {
|
||||||
|
playPcm(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mode = bytes[0];
|
||||||
|
if (mode === 1) {
|
||||||
|
const opusData = bytes.slice(5);
|
||||||
|
decodeOpus(opusData);
|
||||||
|
} else {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
if (message.type === 'attachment_uploaded') fetchText();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)';
|
||||||
|
const stickers = renderStickers(metadata.stickers || []);
|
||||||
|
const embeds = renderEmbeds(metadata.embeds || []);
|
||||||
|
const attachments = renderAttachments(metadata.attachments || []);
|
||||||
|
const badges = document.createElement('div');
|
||||||
|
badges.className = 'badges';
|
||||||
|
if (metadata.reference?.messageId) appendBadge(badges, 'reply', '');
|
||||||
|
if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', '');
|
||||||
|
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);
|
||||||
|
if (embeds.childElementCount > 0) card.appendChild(embeds);
|
||||||
|
if (attachments.childElementCount > 0) card.appendChild(attachments);
|
||||||
|
card.appendChild(badges);
|
||||||
|
el.textList.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
initOpusDecoder();
|
||||||
|
el.listenBtn.textContent = 'Leave Listen Channel';
|
||||||
|
el.listenStatus.textContent = 'speaker on';
|
||||||
|
} else {
|
||||||
|
state.audioContextListen?.close();
|
||||||
|
state.audioContextListen = null;
|
||||||
|
state.opusDecoder = null;
|
||||||
|
state.opusDecoderReady = false;
|
||||||
|
state.opusDecodeQueue = [];
|
||||||
|
el.listenBtn.textContent = 'Join Listen Channel';
|
||||||
|
el.listenStatus.textContent = 'speaker off';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initOpusDecoder() {
|
||||||
|
if (!window.AudioDecoder) {
|
||||||
|
showError('WebCodecs AudioDecoder not supported in this browser');
|
||||||
|
state.isListening = false;
|
||||||
|
el.listenBtn.textContent = 'Join Listen Channel';
|
||||||
|
el.listenStatus.textContent = 'speaker off';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state.opusDecoder = new AudioDecoder({
|
||||||
|
output: (audioData) => playAudioData(audioData),
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Opus decode error:', error);
|
||||||
|
showError(`Opus decode error: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.opusDecoder.configure({
|
||||||
|
codec: 'opus',
|
||||||
|
sampleRate: 48000,
|
||||||
|
numberOfChannels: 2,
|
||||||
|
});
|
||||||
|
state.opusDecoderReady = true;
|
||||||
|
processOpusQueue();
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to init Opus decoder: ${error.message}`);
|
||||||
|
state.isListening = false;
|
||||||
|
el.listenBtn.textContent = 'Join Listen Channel';
|
||||||
|
el.listenStatus.textContent = 'speaker off';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeOpus(opusBuffer) {
|
||||||
|
if (!state.opusDecoderReady) {
|
||||||
|
state.opusDecodeQueue.push(opusBuffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const chunk = new EncodedAudioChunk({
|
||||||
|
type: 'key',
|
||||||
|
timestamp: 0,
|
||||||
|
data: opusBuffer,
|
||||||
|
});
|
||||||
|
state.opusDecoder.decode(chunk);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Opus decode chunk error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processOpusQueue() {
|
||||||
|
while (state.opusDecodeQueue.length > 0 && state.opusDecoderReady) {
|
||||||
|
const buffer = state.opusDecodeQueue.shift();
|
||||||
|
decodeOpus(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAudioData(audioData) {
|
||||||
|
if (!state.audioContextListen) return;
|
||||||
|
const sampleRate = audioData.sampleRate;
|
||||||
|
const frameCount = audioData.numberOfFrames;
|
||||||
|
const audioBuffer = state.audioContextListen.createBuffer(
|
||||||
|
audioData.numberOfChannels,
|
||||||
|
frameCount,
|
||||||
|
sampleRate
|
||||||
|
);
|
||||||
|
audioData.copyTo(audioBuffer);
|
||||||
|
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;
|
||||||
|
audioData.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (state.selectedChannel) url.searchParams.set('channel', state.selectedChannel);
|
||||||
|
else url.searchParams.delete('channel');
|
||||||
|
if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value);
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
await fetchText().catch((error) => showError(error.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
connectWebSocket();
|
||||||
|
loadGuilds().then(refreshStatus).catch((error) => showError(error.message));
|
||||||
|
setInterval(() => {
|
||||||
|
if (state.activeTab === 'text') fetchText().catch(() => {});
|
||||||
|
}, 7000);
|
||||||
@@ -1,957 +1 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=/"></head><body></body></html>
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<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; }
|
|
||||||
|
|
||||||
.sticker-strip, .attachment-strip { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
||||||
.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; }
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
.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>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="shell">
|
|
||||||
<section class="hero">
|
|
||||||
<div class="brand-card">
|
|
||||||
<div class="eyebrow"><span class="pulse"></span> Discord moderation command center</div>
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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;">
|
|
||||||
<button id="toggleBtn" class="btn btn-primary">Start Transmitting</button>
|
|
||||||
<button id="listenBtn" class="btn btn-success">Join Listen Channel</button>
|
|
||||||
</div>
|
|
||||||
<div class="visualizer" id="visualizer"></div>
|
|
||||||
</div>
|
|
||||||
</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');
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
if (message.type === 'attachment_uploaded') fetchText();
|
|
||||||
}
|
|
||||||
|
|
||||||
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)';
|
|
||||||
const stickers = renderStickers(metadata.stickers || []);
|
|
||||||
const embeds = renderEmbeds(metadata.embeds || []);
|
|
||||||
const attachments = renderAttachments(metadata.attachments || []);
|
|
||||||
const badges = document.createElement('div');
|
|
||||||
badges.className = 'badges';
|
|
||||||
if (metadata.reference?.messageId) appendBadge(badges, 'reply', '');
|
|
||||||
if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', '');
|
|
||||||
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);
|
|
||||||
if (embeds.childElementCount > 0) card.appendChild(embeds);
|
|
||||||
if (attachments.childElementCount > 0) card.appendChild(attachments);
|
|
||||||
card.appendChild(badges);
|
|
||||||
el.textList.appendChild(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
await fetchText().catch((error) => showError(error.message));
|
|
||||||
});
|
|
||||||
|
|
||||||
connectWebSocket();
|
|
||||||
loadGuilds().then(refreshStatus).catch((error) => showError(error.message));
|
|
||||||
setInterval(() => {
|
|
||||||
if (state.activeTab === 'text') fetchText().catch(() => {});
|
|
||||||
}, 7000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export async function startRecording(
|
|||||||
onPacket: (chunk) => {
|
onPacket: (chunk) => {
|
||||||
if (chunk.length < 8) return;
|
if (chunk.length < 8) return;
|
||||||
segmentManager.rotateIfNeeded(oggPacketStream);
|
segmentManager.rotateIfNeeded(oggPacketStream);
|
||||||
|
broadcaster.broadcastOpusToWeb?.(chunk, userId);
|
||||||
if (!broadcaster.broadcastPcmToWeb) return;
|
if (!broadcaster.broadcastPcmToWeb) return;
|
||||||
decoder.rotateIfNeeded();
|
decoder.rotateIfNeeded();
|
||||||
decoder.write(chunk);
|
decoder.write(chunk);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface SegmentMetadata extends UserMetadata {
|
|||||||
|
|
||||||
export interface PcmBroadcaster {
|
export interface PcmBroadcaster {
|
||||||
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
|
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
|
||||||
|
broadcastOpusToWeb?: (chunk: Buffer, userId: string) => void;
|
||||||
updateActiveUser?: (
|
updateActiveUser?: (
|
||||||
userId: string,
|
userId: string,
|
||||||
data: { username: string; avatar: string; speaking: boolean },
|
data: { username: string; avatar: string; speaking: boolean },
|
||||||
|
|||||||
217
src/web/dashboardPage.tsx
Normal file
217
src/web/dashboardPage.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import type { MessageRecord } from "../moderation/types";
|
||||||
|
import type { ChannelSummary, GuildSummary, VoiceChannelSummary, VoiceStatus } from "../voiceController";
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
guilds: GuildSummary[];
|
||||||
|
voiceChannels: VoiceChannelSummary[];
|
||||||
|
watchChannels: ChannelSummary[];
|
||||||
|
selectedGuildId: string;
|
||||||
|
selectedChannelId: string;
|
||||||
|
messages: MessageRecord[];
|
||||||
|
status: VoiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(value: string | null): any {
|
||||||
|
if (!value) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(value: unknown): string {
|
||||||
|
return JSON.stringify(value).replace(/</g, "\\u003c");
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageCard({ message }: { message: MessageRecord }) {
|
||||||
|
const metadata = parseMetadata(message.metadata);
|
||||||
|
const content = message.edited_content || message.content || "(empty message)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="event-card" data-message-id={message.id}>
|
||||||
|
<div className="event-head">
|
||||||
|
<div className="author">
|
||||||
|
<div className="avatar">
|
||||||
|
{message.avatar_url ? <img src={message.avatar_url} alt="" /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="name">{message.username || message.user_id}</div>
|
||||||
|
</div>
|
||||||
|
<div className="time">{new Date(message.created_at).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="message-text">{content}</div>
|
||||||
|
|
||||||
|
{metadata.stickers?.length ? (
|
||||||
|
<div className="sticker-strip">
|
||||||
|
{metadata.stickers.map((sticker: any) => (
|
||||||
|
<img key={sticker.id} className="sticker-img" src={sticker.url} alt={sticker.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{metadata.embeds?.length ? (
|
||||||
|
<div className="feed">
|
||||||
|
{metadata.embeds.map((embed: any, index: number) => (
|
||||||
|
<div key={index} className="embed-card">
|
||||||
|
{embed.title ? (
|
||||||
|
embed.url ? (
|
||||||
|
<a className="embed-title" href={embed.url} target="_blank" rel="noreferrer">{embed.title}</a>
|
||||||
|
) : (
|
||||||
|
<div className="embed-title">{embed.title}</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{embed.description ? <div className="embed-description">{embed.description}</div> : null}
|
||||||
|
{embed.fields?.map((field: any, fieldIndex: number) => (
|
||||||
|
<div key={fieldIndex} className="embed-description">{field.name}: {field.value}</div>
|
||||||
|
))}
|
||||||
|
{embed.image || embed.thumbnail ? (
|
||||||
|
<img className="embed-image" src={embed.image || embed.thumbnail} alt={embed.title || "embed image"} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{metadata.attachments?.length ? (
|
||||||
|
<div className="attachment-strip">
|
||||||
|
{metadata.attachments.map((attachment: any) => (
|
||||||
|
<a key={attachment.id} className="attachment-chip" href={attachment.url} target="_blank" rel="noreferrer">
|
||||||
|
{attachment.name} ({(attachment.size / 1024).toFixed(1)}KB)
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="badges">
|
||||||
|
{metadata.reference?.messageId ? <span className="badge">reply</span> : null}
|
||||||
|
{message.thread_id ? (
|
||||||
|
<span className="badge">{metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : "thread"}</span>
|
||||||
|
) : null}
|
||||||
|
{message.edited_at ? <span className="badge edit">edited</span> : null}
|
||||||
|
{message.deleted_at ? <span className="badge delete">deleted</span> : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardPage(props: DashboardProps) {
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<section className="hero">
|
||||||
|
<div className="brand-card">
|
||||||
|
<div className="eyebrow"><span className="pulse" /> Discord moderation command center</div>
|
||||||
|
<h1>Voice. Text. One Watch Floor.</h1>
|
||||||
|
<p className="subtitle">Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.</p>
|
||||||
|
</div>
|
||||||
|
<div className="status-card">
|
||||||
|
<div className="status-row"><span className="status-label">WebSocket</span><span className="status-value"><span id="wsDot" className="dot" /><span id="wsStatusText">Connecting</span></span></div>
|
||||||
|
<div className="status-row"><span className="status-label">Voice Link</span><span id="voiceStatusText" className="status-value">{props.status.connected ? props.status.activeChannelName || "Connected" : "Not connected"}</span></div>
|
||||||
|
<div className="status-row"><span className="status-label">Active Tab</span><span id="activeTabLabel" className="status-value">Voice</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav className="tab-panel">
|
||||||
|
<div className="tabs">
|
||||||
|
<button className="tab-btn active" data-tab="voice">Voice</button>
|
||||||
|
<button className="tab-btn" data-tab="text">Text</button>
|
||||||
|
</div>
|
||||||
|
<div className="filter-row">
|
||||||
|
<span>Channel / Thread</span>
|
||||||
|
<select id="channelFilter" defaultValue={props.selectedChannelId}>
|
||||||
|
<option value="">Select channel</option>
|
||||||
|
{props.watchChannels.map((channel) => (
|
||||||
|
<option key={channel.id} value={channel.id}>{channel.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="errorBox" className="error" />
|
||||||
|
|
||||||
|
<section id="voice" className="tab-content active">
|
||||||
|
<div className="voice-layout">
|
||||||
|
<div className="content-card">
|
||||||
|
<div className="card-title"><h2>Voice Control</h2><span className="mini">bridge</span></div>
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="guildSelect">Guild</label>
|
||||||
|
<select id="guildSelect" defaultValue={props.selectedGuildId}>
|
||||||
|
<option value="">Select guild</option>
|
||||||
|
{props.guilds.map((guild) => <option key={guild.id} value={guild.id}>{guild.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="channelSelect">Voice Channel</label>
|
||||||
|
<select id="channelSelect">
|
||||||
|
<option value="">Select voice channel</option>
|
||||||
|
{props.voiceChannels.map((channel) => <option key={channel.id} value={channel.id}>{channel.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button id="joinVoiceBtn" className="btn btn-success">Join</button>
|
||||||
|
<button id="disconnectVoiceBtn" className="btn btn-danger">Disconnect</button>
|
||||||
|
</div>
|
||||||
|
<div className="voice-status" id="voiceStatusNote">{props.status.connected ? `Connected to ${props.status.activeChannelName}` : "Idle"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-card">
|
||||||
|
<div className="card-title"><h2>Live Audio</h2><span className="mini" id="listenStatus">speaker off</span></div>
|
||||||
|
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "1fr 1fr", marginBottom: 14 }}>
|
||||||
|
<button id="toggleBtn" className="btn btn-primary">Start Transmitting</button>
|
||||||
|
<button id="listenBtn" className="btn btn-success">Join Listen Channel</button>
|
||||||
|
</div>
|
||||||
|
<div className="visualizer" id="visualizer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-card" style={{ marginTop: 18 }}>
|
||||||
|
<div className="card-title"><h2>Participants</h2><span className="mini">speaking now</span></div>
|
||||||
|
<div id="userList" className="participants" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="text" className="tab-content">
|
||||||
|
<div className="content-card">
|
||||||
|
<div className="card-title"><h2>Text Watch</h2><span className="mini">create / edit / delete</span></div>
|
||||||
|
<div id="textList" className="feed">
|
||||||
|
{!props.selectedChannelId ? <div className="empty">Select channel to view text captures</div> : null}
|
||||||
|
{props.selectedChannelId && props.messages.length === 0 ? <div className="empty">No text captures yet</div> : null}
|
||||||
|
{props.messages.map((message) => <MessageCard key={message.id} message={message} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDashboardPage(props: DashboardProps): string {
|
||||||
|
const app = renderToString(<DashboardPage {...props} />);
|
||||||
|
const bootstrap = safeJson({
|
||||||
|
guilds: props.guilds,
|
||||||
|
voiceChannels: props.voiceChannels,
|
||||||
|
watchChannels: props.watchChannels,
|
||||||
|
selectedGuildId: props.selectedGuildId,
|
||||||
|
selectedChannelId: props.selectedChannelId,
|
||||||
|
messages: props.messages,
|
||||||
|
status: props.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<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">
|
||||||
|
<link rel="stylesheet" href="/dashboard.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">${app}</div>
|
||||||
|
<script id="__DASHBOARD_DATA__" type="application/json">${bootstrap}</script>
|
||||||
|
<script src="/dashboard.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { AppError } from "./errors";
|
|||||||
import { createChildLogger, logger } from "./logger";
|
import { createChildLogger, logger } from "./logger";
|
||||||
import { getMetrics, uptimeGauge } from "./metrics";
|
import { getMetrics, uptimeGauge } from "./metrics";
|
||||||
import { discordPlayer } from "./player";
|
import { discordPlayer } from "./player";
|
||||||
|
import { renderDashboardPage } from "./web/dashboardPage";
|
||||||
import type { VoiceController } from "./voiceController";
|
import type { VoiceController } from "./voiceController";
|
||||||
import { getDatabase } from "./muxer-queue";
|
import { getDatabase } from "./muxer-queue";
|
||||||
import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore";
|
import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore";
|
||||||
@@ -70,6 +71,39 @@ export function startWebserver(
|
|||||||
app.use(pinoHttp({ logger }));
|
app.use(pinoHttp({ logger }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get("/", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const guilds = voiceController.listGuilds();
|
||||||
|
const selectedGuildId =
|
||||||
|
typeof req.query.guild === "string" ? req.query.guild : guilds[0]?.id || "";
|
||||||
|
const selectedChannelId =
|
||||||
|
typeof req.query.channel === "string" ? req.query.channel : "";
|
||||||
|
const [voiceChannels, watchChannels] = selectedGuildId
|
||||||
|
? await Promise.all([
|
||||||
|
voiceController.listVoiceChannels(selectedGuildId),
|
||||||
|
voiceController.listWatchableChannels(selectedGuildId),
|
||||||
|
])
|
||||||
|
: [[], []];
|
||||||
|
const messages = selectedChannelId
|
||||||
|
? getMessagesByChannel(getDatabase(), selectedChannelId, 80, 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
res.type("html").send(
|
||||||
|
renderDashboardPage({
|
||||||
|
guilds,
|
||||||
|
voiceChannels,
|
||||||
|
watchChannels,
|
||||||
|
selectedGuildId,
|
||||||
|
selectedChannelId,
|
||||||
|
messages,
|
||||||
|
status: voiceController.getStatus(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "../public")));
|
app.use(express.static(path.join(__dirname, "../public")));
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
@@ -204,6 +238,22 @@ export function startWebserver(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inbound: Discord Opus → tagged chunks → browser (WebCodecs decode)
|
||||||
|
(global as any).broadcastOpusToWeb = (chunk: Buffer, userId: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < userId.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + userId.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
const header = Buffer.alloc(5);
|
||||||
|
header.writeUInt8(1, 0); // mode: 1 = Opus
|
||||||
|
header.writeInt32LE(hash, 1);
|
||||||
|
const packet = Buffer.concat([header, chunk]);
|
||||||
|
wsClients.forEach((client) => {
|
||||||
|
if (client.readyState === 1) client.send(packet);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
(global as any).updateActiveUser = (
|
(global as any).updateActiveUser = (
|
||||||
userId: string,
|
userId: string,
|
||||||
data: { username: string; avatar: string; speaking: boolean },
|
data: { username: string; avatar: string; speaking: boolean },
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true
|
"emitDecoratorMetadata": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
Reference in New Issue
Block a user