refactor: remove voice/recording/media features from frontend + prune lockfiles
- Delete pages: (dashboard)/{recordings,voice,media}/ incl. view.tsx
- Delete components/{voice,media}, hooks/{use-voice,use-recordings,use-media},
lib/audio/ (mic-transmit, pcm-player, wav), api/{voice,recordings,media},
types/{voice,recording,media}, lib/hash.ts
- Cut nav tiles (Active Voice Stages, Voice Recording Archive) from dashboard,
MiniPlayer from ambient-app, hooks/types barrel exports, WS voice/media events
- Re-home guilds + textChannels to messages oRPC router (DB-derived) so the
messages page picker keeps working; guild-picker simplified to text-only
- Clean Channel/AppConfig types of voice remnants
- Delete infra/docker/recordings/ + 11 voice/recording/video spec docs
- pnpm install: prune direct voice deps from gateway + backend lockfiles
(prism-media/opusscript remain only as transitive discord.js deps)
This commit is contained in:
@@ -1,67 +0,0 @@
|
||||
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
|
||||
|
||||
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
|
||||
|
||||
## Masalah (audit)
|
||||
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
|
||||
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
|
||||
play bareng (overlap).
|
||||
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
|
||||
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
|
||||
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
|
||||
dimount → dead code, user tidak lihat status musik di halaman lain.
|
||||
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
|
||||
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
|
||||
|
||||
## Desain
|
||||
|
||||
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
|
||||
Custom player menggantikan `<audio controls>`:
|
||||
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
|
||||
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
|
||||
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
|
||||
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
|
||||
clip otomatis pause yang lain.
|
||||
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
|
||||
|
||||
### B. Recordings view — pasang player baru
|
||||
- Ganti `<audio>` → `<RecordingAudioPlayer src download_url>`.
|
||||
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
|
||||
|
||||
### C. Media view polish
|
||||
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
|
||||
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
|
||||
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
|
||||
- Volume read-only tetap.
|
||||
|
||||
### D. MiniPlayer global
|
||||
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
|
||||
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
|
||||
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
|
||||
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
|
||||
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
|
||||
|
||||
### E. Voice UI
|
||||
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
|
||||
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
|
||||
- Listen: volume slider (input range) wired ke `listen.setVolume`.
|
||||
- Mic volume slider wired ke `mic.setVolume`.
|
||||
|
||||
## File touched
|
||||
| File | Aksi |
|
||||
|---|---|
|
||||
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
|
||||
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
|
||||
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
|
||||
| services/frontend/src/components/media/mini-player.tsx | new |
|
||||
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
|
||||
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
|
||||
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
|
||||
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
|
||||
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
|
||||
|
||||
## Verifikasi
|
||||
1. `pnpm lint` (biome) + `pnpm build` clean.
|
||||
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
|
||||
3. Commit (tanpa trailer) → push → `gh run watch` → live check
|
||||
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Spec: Recordings — filter per user + export WAV (Audacity)
|
||||
|
||||
## Konteks / Gejala
|
||||
Halaman `services/frontend/src/app/(dashboard)/recordings` menampilkan semua
|
||||
rekaman voice (deck). User ingin:
|
||||
1. **Filter per orang** (tampil rekaman satu user saja).
|
||||
2. **Export ke format untuk Audacity** (buka & edit rekaman di Audacity).
|
||||
|
||||
## Fakta saat ini (verified)
|
||||
- Backend `recordings.list` (services/backend/src/orpc/router.ts:291) SUDAH
|
||||
menerima `userId`/`channelId` filter → `RecordingsService.getRecent`.
|
||||
- Frontend `recordingsApi.list(limit, channelId, userId, cursor)` (lib/api/recordings.ts)
|
||||
sudah meneruskan `userId`. `useLoadMoreRecordings` juga sudah bawa userId.
|
||||
- Tapi UI `RecordingsView` (app/(dashboard)/recordings/view.tsx) TIDAK punya
|
||||
filter UI, dan `useRecordingsPage` dipanggil tanpa userId → semua tampil.
|
||||
- Setiap rekaman punya `download_url` (MP3 di TeleUploader), `user_id`, `username`.
|
||||
- Audacity membuka MP3/OGG tapi editing paling bersih dari WAV (uncompressed)
|
||||
/ FLAC (lossless). Backend TIDAK punya ffmpeg & Nix flake backend tak include
|
||||
ffmpeg → transcode server-side bukan pilihan. Browser punya codec MP3 → export
|
||||
WAV via Web Audio API (client-side) adalah solusi self-contained terbaik.
|
||||
|
||||
## Keputusan desain
|
||||
1. **Filter per user**: UI dropdown (Semua User + per user) di header halaman.
|
||||
Memilih user → re-fetch `recordingsApi.list(50, undefined, userId)` (server
|
||||
filter, benar untuk dataset besar + pagination). Dropdown dibangun dari
|
||||
distinct `user_id`/`username` pada items yang sedang tampil.
|
||||
2. **Export WAV (Audacity)**: client-side via Web Audio API.
|
||||
- Per kartu: tombol "WAV" → decode `download_url` → WAV 16-bit PCM → download.
|
||||
- Header: tombol "EXPORT WAV (N)" → gabung (concat) semua rekaman yang
|
||||
sedang tampil (ter-filter) jadi 1 file WAV → download. Ideal untuk analisis
|
||||
/ mixdown per orang.
|
||||
- Implementasi di `lib/audio/wav.ts` (decode + encode + concat), tanpa dep baru.
|
||||
|
||||
## Perubahan
|
||||
|
||||
### Frontend
|
||||
- **`src/lib/audio/wav.ts`** (baru):
|
||||
- `decodeAudio(url: string): Promise<AudioBuffer>` — fetch arrayBuffer →
|
||||
`new AudioContext().decodeAudioData`.
|
||||
- `audioBufferToWav(buf: AudioBuffer, sampleRate=48000): Blob` — PCM 16-bit
|
||||
interleaved, mono→stereo handling, RIFF/WAVE writer. Audacity-importable.
|
||||
- `concatBuffers(buffers: AudioBuffer[]): AudioBuffer` — gabung di channel 0
|
||||
(mono) dengan sample-rate max; untuk export gabungan.
|
||||
- `downloadWav(blob: Blob, filename: string): void` — obj URL + <a download>.
|
||||
- **`src/app/(dashboard)/recordings/view.tsx`**:
|
||||
- Toolbar filter: dropdown user (built from distinct items) + tombol reset.
|
||||
- State `filterUserId`; saat berubah → `recordingsApi.list(50, undefined, id)`
|
||||
→ set ke SWR (key includes filter), reset pagination.
|
||||
- Tombol "WAV" per kartu (disabled jika `!r.download_url`).
|
||||
- Tombol "EXPORT WAV (N)" di header (disabled jika 0 item punya download_url);
|
||||
concat semua items ter-filter yang punya download_url.
|
||||
- Status loading saat export (spinner/disable).
|
||||
- **`src/hooks/use-recordings.ts`**: `useRecordingsPage` menerima `userId?` dan
|
||||
memasukkan ke key + call, supaya filter re-fetch bersih (per-user cache key).
|
||||
`useRecordings`/`useLoadMoreRecordings` propagate `userId`.
|
||||
- **`src/lib/types/recording.ts`**: tidak berubah (userId dari items).
|
||||
|
||||
### Backend / gateway
|
||||
- Tidak ada perubahan. Filter & export sepenuhnya frontend.
|
||||
|
||||
## File yang disentuh (frontend only)
|
||||
- `src/lib/audio/wav.ts` (baru)
|
||||
- `src/app/(dashboard)/recordings/view.tsx`
|
||||
- `src/hooks/use-recordings.ts`
|
||||
|
||||
## Verification
|
||||
1. `cd services/frontend && pnpm typecheck` (tsc --noEmit) — 0 error.
|
||||
2. `pnpm lint` (biome check src/) — exit 0.
|
||||
3. `pnpm build` (next build) — hijau.
|
||||
4. Manual (user): buka /recordings; pilih user di dropdown → hanya rekaman user
|
||||
itu; klik WAV di kartu → file .wav ter-download & terbuka di Audacity; klik
|
||||
EXPORT WAV (filtered) → satu .wav gabungan.
|
||||
5. Push → CI `Build & Deploy (Nix)` (frontend job) hijau → deploy landing.
|
||||
|
||||
## Risiko / Trade-off
|
||||
- Web Audio decode MP3 di client: butuh CORS pada download_url (TeleUploader
|
||||
asepharyana.my.id — sudah same-serve/proxied, CORS ikut origin). Jika 403/CORS
|
||||
gagal, error toaster + fallback manual (RAW MP3 tetap ada).
|
||||
- concat gabungan = mono 48k; Audacity bisa edit per-channel nanti. Acceptable.
|
||||
- Filter client (dropdown dari items yang dimuat) hanya menawarkan user yang
|
||||
sudah tampil; dataset besar bisa pakai search nanti. Server filter benar untuk
|
||||
yang dipilih.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Spec: Recordings v2 — transcription, search, filters, leaderboard, sessions
|
||||
|
||||
## Konteks
|
||||
4 fitur lanjutan untuk halaman /recordings (dipilih user):
|
||||
1. Tampilkan transkripsi + search by kata kunci
|
||||
2. Filter lanjutan: by channel + rentang tanggal
|
||||
3. Kelompokkan klip jadi "sesi rapat" + autoplay berurutan + export satu sesi
|
||||
4. Leaderboard bicara per user + ringkasan
|
||||
|
||||
## Fakta terverifikasi (2026-08-30)
|
||||
- `voice_recordings` kolom: id, user_id, username, avatar_url, guild_id,
|
||||
channel_id, channel_name, filename, size_bytes, download_url, upload_status,
|
||||
upload_error, created_at, uploaded_at, **transcription** (schema
|
||||
`shared/database/schema.ts:246`). Index user_id/channel_id/created_at.
|
||||
- **Transcription 0/10.640** terisi prod: `AI_VOICE_TRANSCRIPTION_ENABLED`
|
||||
default **false** (config/index.ts:333) & tidak diset di BWS env →
|
||||
`transcribeRecording` (voiceTranscriber.ts) langsung return null.
|
||||
`AI_LLM_BASE_URL` + `AI_LLM_API_KEY` SUDAH dikonfig (Whisper via router GMW).
|
||||
- Transcriber hardcode `language: "en"` (voiceTranscriber.ts:34) — salah utk
|
||||
ucapan campur id/en. Utk auto-detect: hapus param `language` (Whisper
|
||||
auto-detect jika tidak diberikan).
|
||||
- Backend `RecordingsService.getRecent` (recordings.service.ts) TIDAK select
|
||||
`transcription`; SUDAH dukung filter `channelId`+`userId`+`cursor`; belum
|
||||
dukung date-range & keyword search. `RecordingRow` interface juga tak punya
|
||||
`transcription`.
|
||||
- Tidak ada kolom `session_id` / `duration_ms` → sesi grouping = heuristik
|
||||
(channel sama + gap created_at), durasi leaderboard = estimasi dari
|
||||
size_bytes (MP3 128kbps: durasi_s ≈ size_bytes*8/128000).
|
||||
|
||||
## Keputusan desain
|
||||
1. **Aktifkan transkripsi (fondasi)**: transcriber auto-detect (hapus
|
||||
`language:"en"`), set secret BWS `AI_VOICE_TRANSCRIPTION_ENABLED=true`
|
||||
(dibaca runtime oleh bws-exec saat service start). Rekaman BARU dapat
|
||||
transkripsi. Backfill rekaman lama TIDAK dilakukan (pilih user: fokus baru;
|
||||
file OGG lama kemungkinan besar sudah tidak dipakai).
|
||||
2. **Backend** — perluas `getRecent`:
|
||||
- select `transcription` (+ interface RecordingRow + FE type)
|
||||
- filter baru: `q` (ILIKE on transcription + username), `startDate`/`endDate`
|
||||
(created_at range, bigint ms)
|
||||
- endpoint baru `recordings.summary`: agregasi per user → {user_id,
|
||||
username, avatar_url, clips, est_duration_s, words, last_at}. `words`
|
||||
dihitung dari transcription (tokenisasi spasi). Return sorted by clips.
|
||||
3. **Frontend**:
|
||||
- Kartu: tampilkan transkripsi (collapse/expand line-clamp) + durasi estimasi.
|
||||
- Toolbar: search box (q), Select channel, date range (start/end), speaker
|
||||
(sudah ada), reset filter.
|
||||
- Tab/segment "Tape Deck" vs "Leaderboard": leaderboard render summary per
|
||||
user + klik → filter deck by user itu.
|
||||
- Sesi grouping (deck view): klip di-group jadi sesi bila channel sama &
|
||||
gap antar klip < SESSION_GAP_MS (default 120s). Header sesi (channel,
|
||||
waktu mulai, jumlah klip, total durasi). Autoplay tombol "Play session" &
|
||||
"Export session WAV" (concat klip sesi via lib/audio/wav.ts yg sudah ada).
|
||||
|
||||
## Perubahan file
|
||||
|
||||
### Gateway (Tahap 1)
|
||||
- `voiceTranscriber.ts`: hapus baris `language: "en"`.
|
||||
- (secret) set `AI_VOICE_TRANSCRIPTION_ENABLED=true` via bws.
|
||||
|
||||
### Backend (Tahap 2)
|
||||
- `recordings.service.ts`: interface + select + getRecent tambah transcription;
|
||||
tambah filter q/startDate/endDate; method getSummary() untuk leaderboard.
|
||||
- `orpc/router.ts`: procedur `recordings.list` schema tambah fields; prosedur
|
||||
baru `recordings.summary`.
|
||||
|
||||
### Frontend (Tahap 3-5)
|
||||
- `lib/types/recording.ts`: tambah transcription, est_duration_s opsional,
|
||||
Summary type.
|
||||
- `lib/api/recordings.ts`: list tambah q/startDate/endDate; + summary().
|
||||
- `hooks/use-recordings.ts`: propagate filter baru ke key+call; hook
|
||||
useRecordingsSummary.
|
||||
- `app/(dashboard)/recordings/view.tsx`: toolbar search+channel+date, kartu
|
||||
transkripsi, tab leaderboard, grouping sesi + autoplay + export sesi.
|
||||
|
||||
## Verification
|
||||
- tiap tahap: gateway `pnpm build` + `biome check src/`; backend `pnpm build`
|
||||
+ `biome check src/ tests/`; FE `pnpm build` + `biome check src/`.
|
||||
- CI Build & Deploy hijau tiap tahap; deploy landing dicek via
|
||||
`systemctl show gmw-<svc>.service --property=ActiveEnterTimestamp`.
|
||||
- Tahap 1c: setelah deploy + rekaman baru, cek
|
||||
`SELECT COUNT(*) FROM voice_recordings WHERE transcription IS NOT NULL`.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Spec: Record video (kamera/screenshare) orang lain — WebRTC receive (Request 2)
|
||||
|
||||
Date: 2026-08-30. Status: Phase A + B DONE (capture → playable MP4); Phase C (UI) open.
|
||||
|
||||
## Why this is hard (ground truth, verified from @discordjs/voice 0.19.2 source)
|
||||
`VoiceReceiver.onUdpMessage` (dist/index.mjs:2059) drops EVERY non-opus RTP
|
||||
packet at line 2068: `if ((msg[1] & 127) !== RTP_OPUS_PAYLOAD_TYPE) return;`.
|
||||
So video (kamera H264? actually Discord uses VP8/H264; screenshare combines with
|
||||
video SSRC) is decrypted-capable but never forwarded. `receiver.parsePacket`
|
||||
(2033) DOES decrypt any payload type generically (audio + video) using
|
||||
`connectionData.{encryptionMode, nonceBuffer, secretKey}` — the only audio gate
|
||||
is the opus check inside onUdpMessage.
|
||||
|
||||
=> FIX: wrap `receiver.onUdpMessage` (like screenShareAudio.ts already does for
|
||||
screen-share AUDIO SSRCs): for packets whose payload type is a VIDEO type
|
||||
(payload 96 VP8, 101/102 H264, 106/116/126/127 AV1, VP9 98...), call
|
||||
`receiver.parsePacket(...)` myself to decrypt, then depacketize + write frames.
|
||||
Delegate opus (120) to the original handler. Delegate audio to original.
|
||||
|
||||
## Audio already works (screenShareAudio.ts). We add VIDEO.
|
||||
|
||||
## Science-of-the-changes below.
|
||||
|
||||
## Phase A — capture + decrypt + depacketize to AnnexB h264 (THIS change)
|
||||
Files (new): `src/modules/voice-recording/videoReceiver.ts`
|
||||
- Hook into `recorder.startRecording` alongside `hookScreenShareAudio`.
|
||||
- Wrap `receiver.onUdpMessage`:
|
||||
- read ssrc = msg.readUInt32BE(8); userData = receiver.ssrcMap.get(ssrc)
|
||||
- if payload type is video AND we have a "watching" subscription for that user
|
||||
(videoSSRC present), decrypt via receiver.parsePacket(...), then:
|
||||
- H264 (101/102 + payload 120 not): strip RTP header, reassemble FU-A
|
||||
fragments into AnnexB NALs (start-code prefixed), buffer until we have
|
||||
a full access unit (keyframe SPS/PPS/IDR or slices), append to a per-
|
||||
user-per-burst `.h264` file.
|
||||
- else delegate to original onUdpMessage.
|
||||
- Watch `receiver.ssrcMap` "create"/"update" for `videoSSRC !== undefined` →
|
||||
signal a video burst started for that user (like screenShareAudio does).
|
||||
- Per-user video files written to `config.RECORDINGS_DIR/<uid>/video-<ts>.h264`.
|
||||
- Guard: skip bot's own video (client.user.id).
|
||||
|
||||
Dependencies: NO new npm deps for Phase A (only crypto already in
|
||||
@discordjs/voice via parsePacket + Buffer). ffmpeg-headless (already in Nix
|
||||
buildInputs) used in Phase B for decode+mux.
|
||||
|
||||
## Phase B — decode + mux to playable MP4/WebM (DONE, commit 999c054b)
|
||||
- `closeBurst` waits for the WriteStream `finish` (full flush/fd close), then
|
||||
`muxToMp4(rawPath)`: `ffmpeg -f h264 -i raw.h264 -c copy -movflags +faststart
|
||||
out.mp4`, deletes raw on success (>=1B mp4), keeps it on failure.
|
||||
- Output: `<RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.mp4`.
|
||||
- ffmpeg is on the gateway runtime PATH (pkgs.ffmpeg-headless, already in the
|
||||
Nix buildInputs for the music/GoLive players).
|
||||
- Test: `tests/videoReceiver.test.ts` muxToMp4 case (real ffmpeg, generates a
|
||||
tiny baseline h264, asserts mp4 non-empty + raw deleted; skipped if no ffmpeg).
|
||||
|
||||
## Phase C — frontend playback + session grouping (follow-up)
|
||||
- Backend oRPC list video files; FE video player, group by call session like audio.
|
||||
|
||||
## Verification
|
||||
- Phase A: join voice, have a member screen-share/camera, confirm `.h264` file
|
||||
grows with NAL frames + keyframes; journal shows "video burst" logs.
|
||||
- Run vitest unit: RTP header strip + FU-A reassembly gives correct bytes.
|
||||
|
||||
## Open questions / risks
|
||||
- Discord codec for camera = H264(101/102); screenshare uses H264 (101/103?)
|
||||
and can also be VP8/VP9. Handle H264 first (depacketize proven), VP8/VP9 in
|
||||
Phase B via ffmpeg RTP input.
|
||||
- Encryption: DAVE (dave_protocol_version) adds a session layer; parsePacket
|
||||
already applies daveSession.decrypt for audio — we must call the SAME
|
||||
parsePacket path so DAVE/encryption is handled identically.
|
||||
- ssrc↔user mapping during a broadcast: videoSSRC is in ssrcMap after the
|
||||
voice state; may need the STREAM_CREATE network events to key reliably.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Spec: Voice auto-reconnect (persistent state + rejoin on drop)
|
||||
|
||||
## Goal
|
||||
Setelah `VoiceController.connect()` berhasil, state "sedang merekam di <guild>/<channel>"
|
||||
disimpan di Postgres. Kalau gateway restart/reboot, atau koneksi voice drop tidak
|
||||
disengaja (dikeluarkan/moved/server restart), gateway otomatis join ulang ke channel
|
||||
yang sama.
|
||||
|
||||
## Requirement mapping (user's ask)
|
||||
- "autoreconnect ke channel yg sama jika server restart atau reboot" → reconnect on
|
||||
startup (ready handler) + keep DB record across graceful shutdown.
|
||||
- "state nya persistent di db" → `voice_auto_reconnect` table.
|
||||
- "rejoin jika tidak sengaja dikeluarkan" → watchdog on `Disconnected`/`Destroyed`
|
||||
(kick / moved / voice server restart) → full rejoin with backoff.
|
||||
- Manual leave (`/voice disconnect`, dashboard disconnect) MUST NOT rejoin.
|
||||
|
||||
## Design decisions
|
||||
1. **New table** `voice_auto_reconnect` (dedicated, not `ui_state`):
|
||||
- `guild_id` text PK
|
||||
- `channel_id` text NOT NULL
|
||||
- `channel_name` text
|
||||
- `connected_at` bigint epoch-ms
|
||||
- `updated_at` bigint epoch-ms
|
||||
DAO: `voiceAutoReconnectRepo.ts` — `upsert(record)`, `list()`, `delete(guildId)`.
|
||||
2. **Write on connect**: `VoiceController.connect()` → after `startRecording` success →
|
||||
`upsert`. IDEMPOTENT (upsert per guild).
|
||||
3. **Clear on manual leave**: `handleVoiceDisconnect` (all) + `handleVoiceDisconnectGuild`
|
||||
pass `clearPersisted: true`. Graceful shutdown `disconnect()` keeps the record.
|
||||
4. **Rejoin on startup**: bootstrap `ready` → `await voiceController.autoReconnect()`
|
||||
(list persisted → connect each, non-fatal on failure).
|
||||
5. **Rejoin on unexpected drop**: monitor per-connection; on `Disconnected`/`Destroyed`
|
||||
with `!intentional` → schedule rejoin `connect(guildId, persistedChannelId)` with
|
||||
backoff (min 2s, max 30s, max 5 attempts). Track `rejoinAttempts`, reset on success.
|
||||
6. **Intentional flag**: `disconnectGuild(guildId, { clearPersisted?, intentional? })`.
|
||||
- shutdown `disconnect()` → `{ intentional: true, clearPersisted: false }`.
|
||||
- manual `disconnect()` (dashboard) → `{ clearPersisted: true }`, sets intentional.
|
||||
- manual `disconnectGuild` → `{ clearPersisted: true }`, sets intentional.
|
||||
The monitor checks `intentional` before rejoin; `clearPersisted` only deletes the row.
|
||||
|
||||
## Files touched
|
||||
- `services/discord-gateway/src/shared/database/schema.ts` — add `pgVoiceAutoReconnectTable`
|
||||
+ types.
|
||||
- `services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts` (NEW) — DAO.
|
||||
- `services/discord-gateway/src/modules/voice-recording/voiceController.ts` — upsert on
|
||||
connect; monitor + rejoin; `autoReconnect()`; `disconnect/disconnectGuild` opts.
|
||||
- `services/discord-gateway/src/modules/command-handler/voice.handler.ts` — manual
|
||||
disconnect/disconnectGuild pass `clearPersisted: true`.
|
||||
- `services/discord-gateway/src/app/bootstrap.ts` — call `voiceController.autoReconnect()`
|
||||
in `ready`.
|
||||
- `services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql` +
|
||||
`meta/_journal.json` entry (apply manually per gmw-ops).
|
||||
|
||||
## Edge cases
|
||||
- Channel deleted / guild lost while persisted → `connect()` throws (channel not found)
|
||||
→ log + delete persisted row (don't retry forever).
|
||||
- Rejoin attempts exhausted → keep row (so next restart retries) + log.
|
||||
- Multiple guilds: per-guild monitor, per-guild persisted row.
|
||||
- Graceful shutdown order: shutdown sets intentional=true (so no rejoin during teardown)
|
||||
but keeps row.
|
||||
|
||||
## Verification
|
||||
- `pnpm typecheck && pnpm build && pnpm lint` in `services/discord-gateway`.
|
||||
- Apply migration `0020` manually; verify table exists.
|
||||
- CI `Build & Deploy (Nix)` green; gateway deploy lands.
|
||||
- Manual: connect via dashboard → check `voice_auto_reconnect` row; simulate drop →
|
||||
confirm rejoin; manual disconnect → row cleared.
|
||||
@@ -1,166 +0,0 @@
|
||||
# Spec: Perbaiki alur voice → recording (miss & terpotong)
|
||||
|
||||
## Konteks & Gejala
|
||||
User melaporkan alur voice sampai recording **banyak miss** (audio tidak tercatat)
|
||||
dan **terpotong** (satu alur bicara kebelah jadi beberapa segmen / audio putus di
|
||||
tengah). Ini domain `services/discord-gateway/src/modules/voice-recording/`.
|
||||
|
||||
Pipeline per user yang mulai bicara (speaking "start"):
|
||||
```
|
||||
receiver.speaking "start" → speakingHandler(userId)
|
||||
├─ await collectUserMetadata(...) ← roundtrip API, subscribe tertunda
|
||||
├─ receiver.subscribe(userId, {end: AfterSilence, duration: 3000ms}) → audioStream
|
||||
├─ attach data/end/error handlers → audioStream.pipe(PacketFilter) → oggPacketStream
|
||||
├─ SegmentManager.open() → OggLogicalBitstream → file .ogg
|
||||
├─ data: SegmentManager.rotateIfNeeded (rotasi 5s) + decoder.write (web PCM tho
|
||||
└─ end: SegmentManager.close() → segmen finish → finalizeSegment upload + transkrip
|
||||
```
|
||||
|
||||
## Root cause (dari pembacaan kode — justifikasi di bawah)
|
||||
|
||||
### A. MISS bagian awal bicara — subscribe tertunda (utama)
|
||||
`speakingHandler.ts:53` melakukan `await collectUserMetadata(...)` SEBELUM
|
||||
`receiver.subscribe`. `collectUserMetadata` (metadata.ts:30) pada cold path
|
||||
(cache miss) melakukan `client.users.fetch` + `guild.members.fetch` roundtrip
|
||||
Discord API (ratusan ms–detik). Selama await, seluruh opus awal bocor → awal
|
||||
kalimat hilang. Cache menghilangkan ini untuk user yang pernah ter-record, tapi
|
||||
user baru/evict (cache max 200) kena setiap kali.
|
||||
|
||||
### B. Double-subscribe race
|
||||
Guard `receiver.subscriptions.has(userId)` di `speakingHandler.ts:63` diletakkan
|
||||
SETELAH `await collectUserMetadata`. Dua event "start" cepat keduanya melewati
|
||||
guard (belum subscribe) → dua subscription → audio terbelah/ganda per user.
|
||||
|
||||
### C. TERPOTONG di jeda bicara — AfterSilence 3000ms
|
||||
`AUDIO_STREAM_SILENCE_DURATION_MS=3000`. Setelah 3s diam, stream auto-`end` →
|
||||
`SegmentManager.close` → segmen baru saat bicara lagi. Jeda normal (berpikir,
|
||||
interupsi) memecah 1 alur bicara jadi beberapa segmen/file. Ini source "terpotong".
|
||||
Segmen pendek hasil jeda <1s juga DIBUANG di `finalizeSegment` (MIN_DURATION_MS=1000)
|
||||
→ miss kata singkat ("ya", "siap").
|
||||
|
||||
### D. Rotasi segmen 5s di tengah bicara
|
||||
`RECORDING_SEGMENT_MS=5000`: `rotateIfNeeded` menutup bitstream & membuka baru
|
||||
setiap 5s walau bicara kontinu. Pipenya di-re-wire di dalam handler data →
|
||||
window drop kecil + continuity file pecah (bukan masalah besar, tapi berkontribusi).
|
||||
|
||||
### E. Tidak ada sinkronisasi "stop" speaking & stream "end" flaky
|
||||
Handler hanya listen "start"; mengandalkan `AfterSilence` untuk emit "end".
|
||||
Bug @discordjs/voice yang dikenal: `AfterSilence` bisa TIDAK emit "end" saat
|
||||
koneksi gagal/teardown → segmen menggantung & tidak pernah finalize/upload
|
||||
(recording "hilang"). Tidak ada watchdog.
|
||||
|
||||
## Scope
|
||||
Hanya `services/discord-gateway/src/modules/voice-recording/` (+ config index bila
|
||||
perlu default baru). Tidak menyentuh playback (player.ts), transmitter (voice dari
|
||||
browser → Discord, arah berlawanan), muxer (konsolidasi akhir), atau screen-share
|
||||
video (hanya audio SSRC via `hookScreenShareAudio` sudah ada & dibiarkan).
|
||||
|
||||
## Perubahan
|
||||
|
||||
### 1. Speak-before-metadata: subscribe LEBIH DULU, metadata paralel
|
||||
`recorder/speakingHandler.ts`:
|
||||
- Pindahkan `receiver.subscribe` + pipeline setup ke ATAS, SEGERA di handler,
|
||||
sebelum `collectUserMetadata`.
|
||||
- Jalankan `collectUserMetadata` secara paralel non-blocking; gunakan metadata
|
||||
cache untuk registrasi segmen saat finalize.
|
||||
- Pertahankan guard skip bot/user (bot bisa dicek dari `client.users.cache` /
|
||||
`client.user.id` tanpa await) SEBELUM subscribe — jangan tunggu fetch user.
|
||||
|
||||
Detail konkret:
|
||||
```
|
||||
async handler(userId):
|
||||
if (userId === client.user?.id) return;
|
||||
if (receiver.subscriptions.has(userId)) return; // guard kini di DEPAN, tanpa await
|
||||
// (belum tahu bot? gunakan cache user; subscribe dulu biar nggak miss)
|
||||
clone = subscribe(userId, {AfterSilence, duration}) // TANPA await metadata
|
||||
setup pipeline (data/end/error, pipe, open segment)
|
||||
collectUserMetadata(...).then(meta => {
|
||||
if (meta.bot) { drain & close subscription (jangan simpan) }
|
||||
else { registrasi ulang metadata utk segmen aktif }
|
||||
})
|
||||
```
|
||||
Karena listener `start` dibuang untuk bot, harus tutup subscription bot tanpa
|
||||
menyimpan segmen (buang hasil). Pakai `receiver.subscriptions.get(userId)?.destroy()`.
|
||||
|
||||
### 2. Selesaikan race double-subscribe (bagian dari #1)
|
||||
Guard `receiver.subscriptions.has(userId)` diletakkan SINCRON di awal (sebelum
|
||||
await). Karena `subscribe` sinkron dan `subscriptions` terisi sinkron saat
|
||||
dipanggil, event "start" kedua yang tiba setelah subscribe akan melihat
|
||||
subscription aktif → di-skip. Tidak ada await antara guard & subscribe.
|
||||
|
||||
### 3. Naikkan AfterSilence + tail-length → kurangi "terpotong"
|
||||
`shared/config/index.ts`:
|
||||
- `AUDIO_STREAM_SILENCE_DURATION_MS` default 3000 → 4000 (beri ruang jeda
|
||||
alami; Discord packet 20ms, 4s masih wajar, tidak membengkak file).
|
||||
Opsional via env override di production (tidak wajib komit env).
|
||||
|
||||
### 4. Segmen berorientasi "burst bicara" daripada rotasi jam
|
||||
`recorder/segment.ts` + `recorder/speakingHandler.ts`:
|
||||
- Hapus/lepas rotasi SEGMEN berbasis waktu (RECORDING_SEGMENT_MS). Alih-alih,
|
||||
satu segmen = satu burst bicara (buka di "start", tutup di "end"/AfterSilence
|
||||
end). Ini menghilangkan pemecahan di tengah kalimat.
|
||||
- `RECORDING_SEGMENT_MS` tetap dipakai untuk rotasi decoder web-PCM (broadcast
|
||||
live), di mana segmen besar bisa menunda frame — biarkan seperti ada.
|
||||
JADI: `SegmentManager.rotateIfNeeded` TIDAK lagi dipanggil pada jalur OGG
|
||||
recording; decoder rotate tetap dijalankan.
|
||||
|
||||
Catatan: dengan satu segmen per burst, ukuran file ~ durasi bicara. File panjang
|
||||
dibutuhkan transkrip & transcode; tidak ada batas keras yang perlu di-override.
|
||||
Watchdog di #5 membatasi durasi menggantung.
|
||||
|
||||
### 5. Watchdog end-of-burst & teardown recovery
|
||||
`recorder/speakingHandler.ts`:
|
||||
- Setelah subscribe, arm timer watchdog (mis. `config twin`/hitung) yang menutup
|
||||
segmen jika `AfterSilence` tidak emit "end" dalam X detik setelah "stop"
|
||||
speaking — atau, lebih sederhana & robust: dengarkan BOTH stream "end" DAN
|
||||
timer dari `receiver.speaking` "stop" (hingga @discordjs/voice meng-klaim
|
||||
AfterSilence). Bila "stop" fire, mulai countdown kecil (mis. 500ms) lalu
|
||||
`segmentManager.close` + `decoder.destroy` + destroy subscription jika stream
|
||||
belum "end".
|
||||
- Ini menutup A: segmen menggantung → jadi pasti finalize & upload.
|
||||
|
||||
Implementasi: subscriptionStream (audioStream) + track milik per-user di
|
||||
Map<userId, {audioStream, segmentManager, decoder, timer}>; handler "stop"
|
||||
menjadwalkan finalize.
|
||||
|
||||
### 6. Naikkan/ambil MIN segmen duration lebih rendah
|
||||
`recorder/segmentFinalizer.ts`: MIN_DURATION_MS 1000 → 300ms. Kata pendek
|
||||
("ya", "siap") tetap tersimpan. GUI biarkan.
|
||||
|
||||
## File yang disentuh
|
||||
- `src/modules/voice-recording/recorder/speakingHandler.ts` (utama: subscribe
|
||||
first, guard depan, watchdog stop, hapus rotasi segmen dari jalur OGG)
|
||||
- `src/modules/voice-recording/recorder/segment.ts` (opsional: API close/open,
|
||||
pertahankan rotate untuk decoder tapi tak dipakai jalur OGG)
|
||||
- `src/modules/voice-recording/recorder/streamSetup.ts` (kecil: backfill subscribe
|
||||
supaya return subscription utk cleanup/destroy bot)
|
||||
- `src/modules/voice-recording/recorder/segmentFinalizer.ts` (MIN_DURATION)
|
||||
- `src/shared/config/index.ts` (default AfterSilence 4000)
|
||||
|
||||
## Yang TIDAK disentuh
|
||||
- `transmitter.ts` (arah browser→Discord, bukan recording)
|
||||
- `player.ts`, `mediaSource.ts`, `screenShareAudio.ts` (hook SSRC sudah benar)
|
||||
- `muxer.ts` (konsolidasi akhir tetap jalan)
|
||||
- Flake/deps/bundle
|
||||
|
||||
## Verification
|
||||
1. `cd services/discord-gateway && pnpm typecheck` (tsc --noEmit) — 0 error.
|
||||
2. `pnpm lint` (biome check src/) — exit 0.
|
||||
3. `pnpm build` (tsc → dist/).
|
||||
4. Unit test baru (vitest, kalau infra tes ada):
|
||||
- subscribe terjadi tanpa await metadata (spy urutan panggilan)
|
||||
- double-start skip lewat guard sinkron
|
||||
- "stop" → watchdog finalize segmen walau stream tidak "end"
|
||||
- MIN_DURATION 300ms menyimpan kata pendek
|
||||
5. Smoke/CI: `nix flake check` (eval). Push → CI `Build & Deploy (Nix)` hijau →
|
||||
deploy landing (`systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp`).
|
||||
6. Runtime manual (user): join voice, bicara dengan jeda >3s, pastikan 1 alur
|
||||
kontinu = 1 segmen utuh (bukan 3), dan awal kata tidak hilang.
|
||||
|
||||
## Risiko / Trade-off
|
||||
- Subscribe-before-metadata: burst bot akan dikumpulkan sesaat lalu dibuang
|
||||
(cost kecil: buang segmen). Lebih baik miss bot daripada miss user.
|
||||
- AfterSilence naik: file lebih panjang sedikit saat jeda; upload/transkrip
|
||||
timeout (transcodeToMp3 30s) tetap aman.
|
||||
- Satu segmen per burst: tidak ada rotasi paksa → durasi segmen = durasi bicara
|
||||
(bisa menit). Transkrip & transcode tetap ok. Watchdog batasi menggantung.
|
||||
@@ -1,177 +0,0 @@
|
||||
# Spec: Receive Others' Screen-Share/Camera Video Under DAVE — Build a DAVE-capable Stream-Watch Connection
|
||||
|
||||
Status: **P1–P3 DONE + 4th CRITICAL FIX deployed (f1a7b0c2); DAVE Ready + MLS handshake CONFIRMED live; P4 = waiting on active streamer to confirm video-burst→mp4**
|
||||
Date: 2026-08-31
|
||||
Author: Hermes
|
||||
Related: `docs/specs/2026-08-31_video-receive-eager-selfbot-connection-spec.md` (superseded by this)
|
||||
`docs/specs/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, selfbot path — dead)
|
||||
|
||||
## Problem / Ground truth (established from live logs 2026-08-31)
|
||||
GMW must record OTHER members' screen-share + camera video in a voice channel it
|
||||
records. Audio works (via `@discordjs/voice` 0.19.2 negotiating DAVE). Video does
|
||||
not. Verified live: the selfbot path (`discord.js-selfbot-v13` eager `joinChannel`
|
||||
→ `joinStreamConnection` → `receiver.createVideoStream`) authenticates but Discord
|
||||
closes the connection with WS code **4017 "E2EE/DAVE protocol required"** (5x →
|
||||
`VOICE_CONNECTION_ATTEMPTS_EXCEEDED`). Root cause: **Discord now REQUIRES DAVE
|
||||
(E2EE) on every voice RTC, and `discord.js-selfbot-v13`'s voice stack predates
|
||||
DAVE** (identify has no `max_dave_protocol_version`, no MLS handshake). The selfbot
|
||||
path is dead, cannot be repaired. Full details: skill `gmw-ops` →
|
||||
`references/video-receive-and-unmute.md` §4.
|
||||
|
||||
Facts:
|
||||
- Watching a stream = a **SEPARATE RTC connection**, not the guild audio socket:
|
||||
gateway `STREAM_WATCH` (op 20) → Discord replies `STREAM_CREATE` (rtc_server_id)
|
||||
+ `STREAM_SERVER_UPDATE` (separate token+endpoint) → client opens its own voice
|
||||
WS+UDP to that endpoint (`StreamConnectionReadonly` in selfbot). The watched
|
||||
video never rides the @discordjs/voice guild socket.
|
||||
- The stream-watch RTC ALSO requires DAVE (same 4017 mechanism).
|
||||
- `@snazzah/davey` (bundled with @discordjs/voice 0.19.2) supports
|
||||
`MediaType.VIDEO` + `Codec.H264` decrypt — DAVE machinery CAN decrypt H264 video.
|
||||
- No off-the-shelf DAVE-capable video-RECEIVE path exists. Closing references:
|
||||
- **Discord-RE/Discord-video-stream** (fork of `@dank074/discord-video-stream`,
|
||||
master 2026-08-28): full DAVE in `src/client/voice/BaseMediaConnection.ts`
|
||||
(Davey `DAVESession` init via `initDave`, MLS key-package / proposals /
|
||||
commit / welcome / transitions; `WebRtcConnWrapper` encrypts audio/video via
|
||||
`daveSession.encrypt(MediaType.VIDEO, codec, …)`). BUT it is STREAMING only
|
||||
(send). No STREAM_WATCH / receive.
|
||||
- `@discordjs/voice`: full DAVE receive for AUDIO only; `DAVESession.decrypt`
|
||||
hardcodes `MediaType.AUDIO` (dist ~line 892); `onUdpMessage` drops non-opus;
|
||||
no STREAM_WATCH.
|
||||
- `discord.js-selfbot-v13`: video receive but no DAVE.
|
||||
|
||||
## Goal
|
||||
Replace the dead selfbot receive path with a **DAVE-capable stream-watch voice
|
||||
connection**: on detecting a member `voiceState.streaming`, send `STREAM_WATCH`,
|
||||
connect a DAVE-authenticated RTC to the stream endpoint, decrypt incoming H264
|
||||
RTP (`MediaType.VIDEO`), reassemble via the existing `H264Depacketizer`, mux to a
|
||||
playable container. Reuse every tested building block already in the repo.
|
||||
|
||||
## Strategy decision (default A; B as fallback) — de-risk in Phase 2
|
||||
Two implementation routes. Decide by Phase 2 prototype result.
|
||||
|
||||
### Strategy A — extend @discordjs/voice's tested native stack (PREFERRED, lighter)
|
||||
Reuse @discordjs/voice 0.19.2 internals (already a runtime dep, already DAVE-tested
|
||||
for audio):
|
||||
- Drive a connection to the stream endpoint using djs/voice's `VoiceWebSocket` +
|
||||
`VoiceUDPSocket` + `DAVESession` (the same classes that work for the guild
|
||||
connection — they take arbitrary endpoint/token/session).
|
||||
- Send the voice identify with `max_dave_protocol_version`, complete the DAVE
|
||||
handshake (Davey), then on receipt of a video RTP packet call
|
||||
`daveSession.decrypt(userId, MediaType.VIDEO, packet)` (Davey exposes
|
||||
`MediaType.VIDEO` + `Codec.H264`) — djs/voice's hardcoded `AUDIO` is the only
|
||||
blocker, fix by invoking Davey directly with `MediaType.VIDEO` for video SSRCs.
|
||||
- STREAM_WATCH sent via the existing selfbot `client.ws.broadcast` (cheap, works —
|
||||
it needs no selfbot voice connection).
|
||||
- Feed decrypted H264 → `H264Depacketizer` → `.h264` → `muxToMp4` (both already
|
||||
in `videoReceiver.ts`, unit-tested).
|
||||
- No new runtime deps. Risk: relies on non-exported djs/voice internals (reachable
|
||||
via `as any`, as the existing `parsePacket` usage shows).
|
||||
|
||||
### Strategy B — port Discord-RE's BaseMediaConnection (heavier, more self-contained)
|
||||
Port `BaseMediaConnection.ts` DAVE handling + `WebRtcConnWrapper` into a
|
||||
receive/watch connection in the gateway. Deps: requires `@lng2004/node-datachannel`
|
||||
(new native WebRTC dep) + `@snazzah/davey` (already available). More code, more
|
||||
risk (native dep in Nix store), but a clean-room receive path decoupled from
|
||||
djs/voice internals. Use only if A proves infeasible.
|
||||
|
||||
## Files touched (Strategy A shape)
|
||||
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
|
||||
(NEW): DAVE stream-watch connection wrapper. Owns, per watched user:
|
||||
- `sendStreamWatch(client, streamKey)` (via `client.ws.broadcast({op:20,
|
||||
d:{stream_key}})`), stream_key = `guild:<gid>:<chid>:<uid>`.
|
||||
- collects STREAM_CREATE (rtc_server_id) + STREAM_SERVER_UPDATE (token+
|
||||
endpoint) via `client.on('raw')` match on stream_key.
|
||||
- builds a djs/voice-style connection to `<endpoint>` with the received token/
|
||||
session; completes DAVE handshake.
|
||||
- `onUdpMessage` wrapper: for video payload types, decrypt with
|
||||
`daveSession.decrypt(userId, MediaType.VIDEO, buf)`, depacketize, write.
|
||||
- teardown on STREAM_DELETE / user stops streaming / leave / channel untrack.
|
||||
- `recorder.ts`: wire `trackChannel`/`untrackChannel` already exist; ensure the
|
||||
selfbot *eager voice connection* attempt is REMOVED (it only 4017-spams logs) —
|
||||
but KEEP `client.ws.broadcast` availability for STREAM_WATCH.
|
||||
- `videoRecorder.ts`: remove the dead selfbot `joinChannel`/`joinStreamConnection`
|
||||
calls; keep the `voiceStateUpdate` streaming detection + teardown bookkeeping as
|
||||
the entry point; delegate the actual receive to `streamWatchReceiver`.
|
||||
- `videoReceiver.ts`: keep `H264Depacketizer` + `muxToMp4` (reused). The
|
||||
guild-socket `hookVideoReceiver` can be removed or left inert.
|
||||
- Tests: `tests/streamWatchReceiver.test.ts` (DAVE-handshake stub, RTP decrypt path
|
||||
with a mocked Davey, STREAM_WATCH packet shape); keep `tests/videoReceiver.test.ts`.
|
||||
|
||||
## Phases (each independently verifiable)
|
||||
1. **Phase 1 (this session): spec + source reconnaissance.** Confirm djs/voice
|
||||
internals are reachable (VoiceWebSocket/VoiceUDPSocket/DAVESession exports &
|
||||
shapes), confirm Davey `MediaType.VIDEO` decrypt signature, confirm how a raw
|
||||
stream-watch connection's identify/select-protocol flows. Verify the selfbot
|
||||
`streamKey` format + `raw` STREAM_CREATE/SERVER_UPDATE payload. GATE: accurate
|
||||
spec + no unknowns blocking A.
|
||||
2. **Phase 2: de-risk prototype.** Standalone script (not in the gateway) that:
|
||||
logs into the same selfbot token, joins a real voice channel, sends STREAM_WATCH
|
||||
for a live streamer, receives STREAM_CREATE/SERVER_UPDATE, and attempts a
|
||||
DAVE-authenticated connect + receive of ≥1 H264 packet to prove the path before
|
||||
any gateway integration. GATE: at least one decrypted H264 NAL captured in the
|
||||
lab.
|
||||
3. **Phase 3: gateway integration** per files-touched. GATE: typecheck + build +
|
||||
biome + unit tests green; CI deploy ok.
|
||||
4. **Phase 4: live verify.** With a real streamer in a recorded channel: journal
|
||||
shows `STREAM_WATCH sent`, `DAVE ready`, `Video burst opened`, and a playable
|
||||
`.h264`/`.mp4`/`.mkv` on disk. GATE: playable file with real video content.
|
||||
|
||||
## Risks / open questions
|
||||
- Does Discord require the stream-watch connection to use the SAME session_id as
|
||||
the bot's active voice session, or a fresh one? (Affects identify.) Resolve in P2.
|
||||
- Which video codec does Discord actually send for camera vs GoLive (H264 likely,
|
||||
but VP9/AV1 possible) — the depacketizer only handles H264. P2 measures the
|
||||
payload type live; add depacketizers for other codecs only if observed.
|
||||
- djs/voice `DAVESession`/`VoiceUDPSocket` reachability via `as any` must be
|
||||
confirmed against the installed 0.19.2 build (P1).
|
||||
- The separate stream RTC may need `selectProtocol`/SDP even for receive-only; the
|
||||
Discord-RE SDP shows a `m=video ... inactive` section. Follow the same shape.
|
||||
|
||||
## Verification (overall)
|
||||
- Per-phase gates above.
|
||||
- No regression: audio recording + message capture still work after changes.
|
||||
- `pnpm typecheck && pnpm build && pnpm lint` green in discord-gateway.
|
||||
- Commit + push; CI `Build & Deploy (Nix)` green; live streamer produces a file.
|
||||
|
||||
## Phase 1 findings (CONFIRMED 2026-08-31, Strategy A feasible)
|
||||
- `@discordjs/voice` 0.19.2 dist/index.mjs PUBLICLY exports exactly the primitives
|
||||
needed: `DAVESession`, `Networking`, `NetworkingStatusCode`, `VoiceConnection`,
|
||||
`VoiceReceiver`, `VoiceUDPSocket`, `VoiceWebSocket`, `SSRCMap`,
|
||||
`RTP_OPUS_PAYLOAD_TYPE` (export block ~3143). So a stream-watch connection can be
|
||||
built OUTSIDE the lib using these constructors — no `as any` needed for the heavy
|
||||
lifting.
|
||||
- `Networking` child wiring (~line 1364-1484): `new VoiceWebSocket('wss://' +
|
||||
endpoint + '?v=8', debug)`; on WS open send Identify `{op, d:{server_id,
|
||||
user_id, session_id, token, max_dave_protocol_version: getMaxProtocolVersion()}}`;
|
||||
`createDaveSession(protocolVersion)` → `new DAVESession(protocolVersion, userId,
|
||||
channelId, {decryptionFailureTolerance})` then `.reinit()`; UDP via
|
||||
`new VoiceUDPSocket({ip, port})` after Ready gives modes + ssrc.
|
||||
- `DAVESession` wraps `@snazzah/davey` `Davey.DAVESession(protocolVersion, userId,
|
||||
channelId)`; on network packets it calls `this.session.decrypt(userId,
|
||||
Davey.MediaType.AUDIO, packet)` — hardcoded AUDIO (line ~892). For video we call
|
||||
Davey directly with `MediaType.VIDEO` + `Codec.H264`.
|
||||
- `@snazzah/davey` MediaType enum: AUDIO=0, VIDEO=1; Codec H264=4; methods
|
||||
`decrypt(mediaType, codec, packet): Buffer` + `encrypt(...)`. Confirmed in davey
|
||||
index.d.ts.
|
||||
- Stream key format (selfbot VoiceConnection.js ~1240): `guild:<gid>:<chid>:<uid>`
|
||||
for guild channels; `STREAM_WATCH` = gateway op 20, `d:{stream_key}`;
|
||||
`sendSignalScreenshare` = `client.ws.broadcast({op:20,d:{stream_key}})`. Replies
|
||||
come as gateway `raw` events `STREAM_CREATE` (`d.rtc_server_id`) +
|
||||
`STREAM_SERVER_UPDATE` (`d.token`, `d.endpoint`); selfbot routes them to the
|
||||
stream connection via `client.on('raw')` matching `d.stream_key`, setting
|
||||
`setSessionId(sessionId)` + `setTokenAndEndpoint(token, endpoint)` (Watch case in
|
||||
`StreamConnectionReadonly`).
|
||||
- Discord-RE reference for the identify SDP: stream connections send a `m=video`
|
||||
section with `a=inactive` (receive-only-ish) + standard DAVE/VoiceOpCodes
|
||||
(op 0 identify, op 2 select protocol incl. `max_dave_protocol_version`). See
|
||||
`BaseMediaConnection.handleProtocolAck` + `initDave`.
|
||||
- Decision: proceed with **Strategy A**. Selfbot code to REMOVE: the eager
|
||||
`ensureSelfbotVoice` join + `joinStreamConnection`/`receiver.createVideoStream`
|
||||
in `videoRecorder.ts` (proven dead — 4017). Keep the `voiceStateUpdate` streaming
|
||||
detection + bookkeeping; swap the receive plumbing to a new `streamWatchReceiver`
|
||||
driven by a djs/voice-style connection. STREAM_WATCH itself still sent via the
|
||||
selfbot `client.ws.broadcast` (needs only the WS, not a selfbot voice conn).
|
||||
- OPEN (resolve in Phase 2 lab): (a) whether the stream connection's identify must
|
||||
use the bot's ACTIVE voice session_id or a fresh one; (b) actual video codec Discord
|
||||
sends for camera vs GoLive (measure payload type live; H264 assumed, VP9/AV1 possible
|
||||
→ add depacketizers only if observed).
|
||||
@@ -1,93 +0,0 @@
|
||||
# Spec: Fix Video Capture — Eagerly Establish the Selfbot Voice Connection at Join Time
|
||||
|
||||
Status: PLANNED
|
||||
Date: 2026-08-31
|
||||
Author: Hermes
|
||||
Related: `docs/specs/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, made Option A this fix)
|
||||
|
||||
## Symptom (from live logs, 2026-08-31 ~12:34)
|
||||
A user was actively screen-sharing + on camera in the recorded voice channel.
|
||||
The gateway recorded MANY users' audio (.ogg) fine, but video capture produced
|
||||
nothing. The only video signal in `journalctl -u gmw-discord-gateway` was:
|
||||
|
||||
```
|
||||
[VOICE (guild:2)]: Sending voice state update: {"self_mute":false,...,"flags":2}
|
||||
[VOICE] received voice state update: {member hunterz ...} # OTHER user, not bot
|
||||
[VOICE] connection? true, guild session channel
|
||||
[VOICE (guild:2)]: Setting sessionId <S> (stored as "undefined")
|
||||
[VOICE (guild:2)]: Authenticated with sessionId <S> # debug print only
|
||||
[VOICE (guild:2)]: Authenticate failed - VOICE_CONNECTION_TIMEOUT # +15s
|
||||
video-recorder: userId=..., "Connection not established within 15 seconds."
|
||||
```
|
||||
|
||||
## Root cause (verified against discord.js-selfbot-v13 3.7.1 source)
|
||||
The gateway records audio via `@discordjs/voice` (`joinVoiceChannel` + adapter).
|
||||
Video receive lives on the SEPARATE selfbot `ClientVoiceManager.connection`
|
||||
(a singleton `VoiceConnection`). `videoRecorder.ts` currently calls
|
||||
`client.voice.joinChannel(channel)` LAZILY — only when a `voiceStateUpdate`
|
||||
shows `newState.streaming === true`.
|
||||
|
||||
At that moment the bot is ALREADY connected to the channel via @discordjs/voice.
|
||||
A selfbot `joinChannel` then does `VoiceConnection.authenticate()` →
|
||||
`sendVoiceStateUpdate()`, and waits for a fresh `VOICE_SERVER_UPDATE`
|
||||
(`setTokenAndEndpoint`) + `VOICE_STATE_UPDATE` (`setSessionId`) to reach
|
||||
`checkAuthenticated()` (needs token+endpoint+sessionId). Because the bot is
|
||||
already in an established voice session, Discord does NOT emit a new
|
||||
`VOICE_SERVER_UPDATE` for the lazy selfbot re-join → token/endpoint never set →
|
||||
15s `VOICE_CONNECTION_TIMEOUT`.
|
||||
|
||||
This is fatal to video: `joinStreamConnection(userId)` (STREAM_WATCH op 20) and
|
||||
`receiver.createVideoStream(userId, out)` (Recorder/ffmpeg) BOTH live on the
|
||||
parent selfbot `VoiceConnection` and require it `CONNECTED` (its own voice
|
||||
WS+UDP socket feeds `PacketHandler.push`, authenticated with
|
||||
`authentication.secret_key`).
|
||||
|
||||
## Fix — establish the selfbot connection eagerly, at voice-join time
|
||||
The selfbot `VoiceConnection` must exist and be `CONNECTED` before any streamer
|
||||
appears. Establish it once, synchronously alongside the @discordjs/voice join in
|
||||
`recorder.startRecording`, so it rides the bot's FRESH voice join — when Discord
|
||||
DOES emit VOICE_SERVER_UPDATE. Then cache it and let `videoRecorder` reuse it.
|
||||
|
||||
Ordering: in `startRecording`, after the @discordjs/voice `joinVoiceChannel`
|
||||
returns (and retries) — fire `ensureSelfbotVoice(channel)` best-effort:
|
||||
1. `await client.voice.joinChannel(channel, { selfMute:false, selfDeaf:false,
|
||||
selfVideo:false })` (rejects ~VOICE_CONNECTION_TIMEOUT on failure → log +
|
||||
return null; do NOT block audio).
|
||||
2. Cache the returned selfbot `VoiceConnection` keyed by guildId.
|
||||
3. Wire teardown: on `recorder` voice stop / destroyed → `untrackChannel` +
|
||||
destroy the cached selfbot connection (`disconnect()`).
|
||||
|
||||
`videoRecorder.startVideoRecording` then uses the cached selfbot connection:
|
||||
- If cached & `status === CONNECTED` → use it.
|
||||
- Else → fall back to a lazy `joinChannel` (still best-effort).
|
||||
|
||||
## The two-connection coexistence risk (must verify live)
|
||||
@discordjs/voice (audio) and the selfbot `VoiceConnection` (video) each open
|
||||
their OWN low-level voice WS+UDP on the same session. The spec's original
|
||||
open-question flagged this. Mitigations:
|
||||
- Clear logging: `Selfbot voice connected (guild=...)`, plus a periodic
|
||||
`djs/voice status` log so we can confirm audio stays `READY` while the selfbot
|
||||
connection is up.
|
||||
- If Discord kicks/breaks the audio connection, logs will show
|
||||
@discordjs/voice `Disconnected`/reconnect churn — we detect and pivot.
|
||||
|
||||
## Files touched
|
||||
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts`:
|
||||
add `ensureSelfbotVoice(channel)` (return cached/connected), use it in
|
||||
`startVideoRecording`, add `destroyGuildSelfbotVoice(guildId)`,
|
||||
richer status logging.
|
||||
- `services/discord-gateway/src/modules/voice-recording/recorder.ts`: call
|
||||
`ensureSelfbotVoice(channel)` after `joinVoiceChannel` (best-effort);
|
||||
call `destroyGuildSelfbotVoice` on voice stop/destroy.
|
||||
- Tests: `tests/videoRecorder.test.ts` (update to assert eager-connection reuse
|
||||
+ status gating).
|
||||
|
||||
## Verification
|
||||
1. `pnpm typecheck` + `pnpm build` + biome clean (discord-gateway).
|
||||
2. Tests green.
|
||||
3. Commit + push; CI `Build & Deploy (Nix)` green, service restarts.
|
||||
4. LIVE (deploy): join a channel with the bot → journal shows
|
||||
`Selfbot voice connected` (parent CONNECTED). When a member streams →
|
||||
`Sender signal screenshare` / `Video recorder ready` + a `.mkv` under
|
||||
`<RECORDINGS_DIR>/<uid>/video-*.mkv`; playable via ffmpeg. Confirm audio
|
||||
recording still flows (no djs/voice reconnect churn).
|
||||
@@ -1,97 +0,0 @@
|
||||
# Spec: Record Other Users' Video (Camera / Screen Share) — Phase C
|
||||
|
||||
Status: PLANNED (not built)
|
||||
Date: 2026-08-31
|
||||
Author: Hermes
|
||||
Related: `docs/specs/2026-08-30_video-record-receive-spec.md` (Phase A/B — raw UDP hook, superseded for receive)
|
||||
|
||||
## TL;DR — what changed vs Phase A/B
|
||||
|
||||
Phase A/B (commit `999c054b` etc.) hooked `@discordjs/voice`'s UDP socket to capture non-opus RTP and
|
||||
depacketize H264 → mp4. **It captured ZERO video** because `@discordjs/voice` never authorizes the bot to
|
||||
receive others' video (no STREAM_WATCH). This spec replaces that approach with the **native, selfbot-lib
|
||||
receive path**, which is battle-tested and does the authorization + decryption + ffmpeg muxing for us.
|
||||
|
||||
## Ground truth (verified in discord.js-selfbot-v13 3.7.1 source)
|
||||
|
||||
1. `ClientVoiceManager.joinChannel(channel, config)` → a **selfbot `VoiceConnection`** with
|
||||
`.receiver` (`VoiceReceiver` → `PacketHandler`). [ClientVoiceManager.js:102-118]
|
||||
2. `VoiceConnection.receiver` is created in the constructor. [VoiceConnection.js:140]
|
||||
3. `VoiceReceiver.createVideoStream(user, output)` → `PacketHandler.makeVideoStream` → **`Recorder`**
|
||||
(ffmpeg that muxes H264+Opus RTP over UDP → **Matroska (.mkv)**). [Receiver.js, Recorder.js]
|
||||
4. `PacketHandler` routes: video RTP → Recorder UDP 65506, opus RTP → UDP 65510; decodes all via
|
||||
`connection.authentication.{secret_key, mode}` (supports `aead_aes256_gcm_rtpsize` and
|
||||
`aead_xchacha20_poly1305_rtpsize` = DAVE-compatible). [PacketHandler.js:115-155, 195-240]
|
||||
5. `StreamConnectionReadonly.joinStreamConnection(userId)` + `sendSignalScreenshare()` sends
|
||||
gateway op `STREAM_WATCH` so Discord actually forwards the streamer's RTP to us. [VoiceConnection.js:1100-1240]
|
||||
6. `VoiceState.streaming` = `data.self_stream ?? false` — lets us detect a streamer on voice state update. [VoiceState.js:94]
|
||||
|
||||
## Problem / the crux
|
||||
|
||||
The gateway's voice today is **`@discordjs/voice`** (audio + music + GoLive-send). The selfbot-lib
|
||||
video-receive path lives on the **selfbot-lib `VoiceConnection`** — a separate voice stack. Two options:
|
||||
|
||||
### Option A (RECOMMENDED): Parallel selfbot video-watch connection
|
||||
Keep `@discordjs/voice` for everything it does today. Add a **second, selfbot-lib voice connection**
|
||||
to the same channel whose ONLY job is to watch + record others' video.
|
||||
|
||||
- Pros: zero regression risk to audio/music/screenshare-send; uses native `createVideoStream` → mk4.
|
||||
- Cons: two voice connections for the same bot user in one channel. Need to verify Discord tolerates it
|
||||
(real selfbots like Discord-RE do exactly this for multi-stream). The selfbot lib's `joinChannel`
|
||||
reuses `ClientVoiceManager.connection` (it's a singleton) — see caveat below.
|
||||
|
||||
### Option B: Migrate primary voice to selfbot lib
|
||||
Make the selfbot `VoiceConnection` THE voice layer (it also does audio via `receiver.createStream`).
|
||||
- Pros: one connection; video+audio unified.
|
||||
- Cons: large refactor; high regression risk to the entire existing audio/music/GoLive stack. NOT chosen now.
|
||||
|
||||
## CAVEAT — ClientVoiceManager.connection is a singleton
|
||||
`ClientVoiceManager.connection` is a single `VoiceConnection`. The gateway's `@discordjs/voice` adapter and
|
||||
the selfbot lib both drive the same client voice state. Need to verify whether `client.voice.joinChannel()`
|
||||
can coexist with the active `@discordjs/voice` session, or whether we must create the selfbot VoiceConnection
|
||||
manually / re-use the existing voice state. This is the #1 technical risk to validate in the spike before
|
||||
committing to Option A.
|
||||
|
||||
## Implementation plan (Option A)
|
||||
|
||||
### 1. Streamer detector (new: `modules/voice-recording/videoRecorder.ts`)
|
||||
- Listen to voice state updates (`client.on('voiceStateUpdate')` or the existing voice-state hook).
|
||||
- When `voiceState.streaming === true` for a member in the bot's channel → candidate to record.
|
||||
- Skip bot's own user id (unless we also want self-video; default skip).
|
||||
|
||||
### 2. Watch + record wiring
|
||||
- Ensure a selfbot-lib `VoiceConnection` exists for the channel (spike: `client.voice.joinChannel(channel)`,
|
||||
fallback: build a `VoiceConnection` directly from the existing voice auth).
|
||||
- `await selfbotVoiceConn.joinStreamConnection(userId)` → STREAM_WATCH op 20.
|
||||
- `const recorder = selfbotVoiceConn.receiver.createVideoStream(userId, outPath)` where outPath points under
|
||||
`<RECORDINGS_DIR>/<uid>/video-<streamKey>-<ts>.mkv` (Recorder outputs MKV natively).
|
||||
- On `recorder.on('ready')` → mark recording; `recorder.on('closed')` → finalize.
|
||||
- Transcript later: MKV → mp4 via ffmpeg (Phase B `muxToMp4` can accept mkv) for dashboard playback.
|
||||
|
||||
### 3. Teardown
|
||||
- When `voiceState.streaming === false` / user leaves / channel emptied → `recorder.destroy()`,
|
||||
`selfbotVoiceConn.streamWatchConnection.delete(userId)` / `sendStopScreenshare()`.
|
||||
|
||||
### 4. Frontend (Phase UI, later)
|
||||
- oRPC/backend list `.mkv` per call session + FE `<video>` player (mirror audio recordings UI).
|
||||
|
||||
## Files touched
|
||||
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts` (new)
|
||||
- `services/discord-gateway/src/modules/voice-recording/recorder.ts` (wire streamer detector on voice join)
|
||||
- Possibly `voiceController.ts` (voice state update subscription)
|
||||
- Tests: `tests/videoRecorder.test.ts` (mock selfbot VoiceConnection + Recorder)
|
||||
|
||||
## Verification
|
||||
1. `pnpm typecheck` + `pnpm build` + biome clean in discord-gateway.
|
||||
2. Unit: Recorder wiring + streamer detection with mocked VoiceConnection.
|
||||
3. Live (deploy): user shares screen → journal shows `STREAM_WATCH` sent + `Recorder ready` + `.mkv` file
|
||||
appears under recordings dir; playable via ffmpeg.
|
||||
4. CI Build & Deploy (Nix) green.
|
||||
|
||||
## Open questions for spike (before full build)
|
||||
- [ ] Can `client.voice.joinChannel()` run alongside the active `@discordjs/voice` session, or does the
|
||||
singleton `ClientVoiceManager.connection` collide / tear down the existing audio connection?
|
||||
- [ ] Does the selfbot `VoiceConnection` need the bot's `video: true` flag in IDENTIFY to receive video
|
||||
(it advertises `streams` in IDENTIFY — see BaseMediaConnection/identify vs selfbot VoiceConnection)?
|
||||
- [ ] Does `Recorder` (spawns system ffmpeg, UDP loopback on 65506/65510) work in the Nix store runtime
|
||||
(ffmpeg-headless on PATH confirmed; UDP loopback fine)?
|
||||
@@ -1,33 +0,0 @@
|
||||
# Video Recording Splitting — Like Voice Recording
|
||||
|
||||
## Goal
|
||||
Camera + screen share (stream watch) recording should split into per-burst
|
||||
segments just like voice recording does — each time a streamer pauses/stops
|
||||
and resumes, a new MP4 segment is created and registered in the DB + uploaded.
|
||||
|
||||
## Voice Recording Model (to replicate)
|
||||
1. `receiver.speaking.start` → new OGG segment per burst
|
||||
2. AfterSilence (4000ms) → stream "end" → segment finalized + uploaded
|
||||
3. Each segment → DB insert → OGG→MP3 transcode → upload → update DB
|
||||
4. File stored as `<userId>/<startTime>.ogg` + `.json`
|
||||
|
||||
## Video Recording Splitting
|
||||
1. DAVE video RTP → depacketize H264 → write to current segment .h264
|
||||
2. Silence detection: no H264 packets for 4000ms → close segment → flush →
|
||||
mux to MP4 → insert DB record → upload → start new segment on next packet
|
||||
3. Each segment: `<userId>/video-<channelId>-<startTime>.h264` → `.mp4`
|
||||
4. DB: reuse `voice_recordings` table (filename indicates video, e.g. `video-XXX-1234.mp4`)
|
||||
5. Upload: MP4 to TeleUploader (no transcode needed — MP4 plays everywhere)
|
||||
|
||||
## Files Modified
|
||||
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
|
||||
— Main change: silence-based splitting + DB registration + upload
|
||||
|
||||
## Constants
|
||||
- `VIDEO_SILENCE_MS = 4000` (matches voice AfterSilence)
|
||||
- `VIDEO_MIN_SEGMENT_MS = 1000` (skip segments <1s — avoid noise)
|
||||
|
||||
## Verification
|
||||
- `pnpm typecheck` in `services/discord-gateway`
|
||||
- `pnpm build` (dist/ is the deployed artifact)
|
||||
- Push → CI deploy → live test with a streamer
|
||||
@@ -1,81 +0,0 @@
|
||||
# Spec: Selfbot-Viable Video Capture — manual screen-share watch command (Phase D)
|
||||
|
||||
Status: PLANNED (not yet built)
|
||||
Date: 2026-09-02
|
||||
Author: Hermes
|
||||
Related: `docs/specs/2026-08-31_video-receive-phaseC-spec.md` (auto-receive, superseded
|
||||
for selfbot), `gmw-ops/references/selfbot-presence-detection-limits.md`,
|
||||
`gmw-ops/references/discord-voice-fork-video-receive.md`
|
||||
|
||||
## TL;DR — the decisive finding (verified live 2026-09-02)
|
||||
|
||||
User insists on keeping the **selfbot** (no bot-token migration). Live diagnostics prove
|
||||
a selfbot CANNOT auto-detect other members' camera/share because:
|
||||
- It never receives `VOICE_STATE_UPDATE` for other members (only its own).
|
||||
- `guild.members.fetch()` → 403, `GET /channels/{id}/voice-states` → 404.
|
||||
- No `GUILD_CREATE`, no `READY.broadcaster_user_ids` presence.
|
||||
- `scanExistingStreamers` + `handleVoiceStateUpdate` (the only two `startStreamWatch`
|
||||
triggers) are therefore both **dead on a selfbot**.
|
||||
- No manual watch command exists today, so even on-demand capture is impossible.
|
||||
|
||||
→ The ONE selfbot-viable path is a **manual, operator-initiated STREAM_WATCH** on a
|
||||
member known to be screen-sharing. Gateway op 20 (STREAM_WATCH) is **NOT gated on
|
||||
bot-vs-user**; the DAVE handshake to Ready+MLS was already verified live in earlier
|
||||
sessions. The receive/mux/segment/upload pipeline (`streamWatchReceiver.ts`) is already
|
||||
built and only lacks a real streamer to produce its first `.mp4`.
|
||||
|
||||
Camera-of-others is NOT viable on a selfbot even with `unknown-ssrc` fallback:
|
||||
`@discordjs/voice` `parsePacket` calls `daveSession.decrypt(packet, userId)` keyed per
|
||||
REAL userId (vendor fork dist/index.js:2143), so a fake id selects no MLS decryptor →
|
||||
garbage, not H264. (The uncommitted `unknown-ssrc` change was reverted this session.)
|
||||
|
||||
Selfbot CAN capture the OWNER's own video (its own VOICE_STATE_UPDATE + fork op12
|
||||
videoSSRC are attributable), but `videoRecorder.ts` hard-skips its own id — parameterized
|
||||
self-capture is a follow-up, not the default.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a **manual watch command** so an operator can say "record <member>'s screen share"
|
||||
and the gateway `startStreamWatch`s that member → DAVE watch → per-burst `.mp4` segments
|
||||
(mirroring voice silence split) → upload → DB `voice_recordings` → dashboard `<video>`.
|
||||
|
||||
This is the only form of OTHER-member video capture a selfbot can deliver, and it is
|
||||
genuinely buildable with the existing receive pipeline.
|
||||
|
||||
## Scope / files
|
||||
|
||||
Gateway (`services/discord-gateway`):
|
||||
- New command type `VIDEO_WATCH` + handler in `command-handler/` (dedicated
|
||||
`video.handler.ts`), routed via `createHandlerRegistry`.
|
||||
- Handler resolves a VoiceChannel (from persisted `voice_auto_reconnect` / active
|
||||
connections) + target memberId from the command payload, calls
|
||||
`startStreamWatch(channel, memberId)` (already exported).
|
||||
- Idempotent (startStreamWatch early-returns if a watch exists); a `VIDEO_UNWATCH`
|
||||
command calls `stopStreamWatch(guildId, userId)`.
|
||||
- Reply: success/failure via the standard `CommandReply` publish.
|
||||
|
||||
Backend (`services/backend`):
|
||||
- oRPC procedure (or the existing command bridge) that publishes a `VIDEO_WATCH`
|
||||
command to `backend:command` with `{ guildId, channelId, userId }`. Reuse the same
|
||||
bridge the FE already uses for voice commands.
|
||||
|
||||
Frontend (`services/frontend`):
|
||||
- A "Video Watch" control: pick a voice member + a "Record screen" button → calls the
|
||||
backend procedure. Shows live status (watching / recording / segments uploaded).
|
||||
|
||||
(Each layer optional independently; gateway alone gives a Redis-testable path.)
|
||||
|
||||
## Verification
|
||||
1. `pnpm typecheck` + `pnpm build` + `biome check src/` green in discord-gateway.
|
||||
2. Unit test: handler publishes reply + calls startStreamWatch with the right args
|
||||
(mock the module).
|
||||
3. Live: operator invokes `!videorec <member>` while that member screen-shares →
|
||||
journal shows `Sending STREAM_WATCH` → `STREAM_CREATE` → `DAVE watch READY` → `Video
|
||||
burst opened` → `Video muxed to mp4` → a `video-*.mp4` appears under
|
||||
`<recordingsDir>/<uid>/` and a `video-%` row lands in `voice_recordings`.
|
||||
4. `Build & Deploy (Nix)` CI green.
|
||||
|
||||
## Out of scope (documented dead ends on selfbot)
|
||||
- Auto camera/share capture of OTHER members (impossible at detection layer).
|
||||
- Camera-of-others via `unknown-ssrc` (DAVE decrypt needs real userId).
|
||||
- Bot-token migration (user declined).
|
||||
@@ -1,3 +0,0 @@
|
||||
*
|
||||
!.gitkeep
|
||||
!README.md
|
||||
@@ -1,15 +0,0 @@
|
||||
# Recordings Directory
|
||||
|
||||
This directory is mounted as a volume in the discord-gateway container.
|
||||
Voice recordings are stored here with the following structure:
|
||||
|
||||
```
|
||||
recordings/
|
||||
├── <user-id>/
|
||||
│ ├── <timestamp>.ogg
|
||||
│ └── <timestamp>.json
|
||||
└── sessions/
|
||||
└── <session-id>.json
|
||||
```
|
||||
|
||||
The directory must be writable by UID 1000 (app user in the container).
|
||||
Generated
+2
-208
@@ -8,9 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@discordjs/voice':
|
||||
specifier: ^0.19.2
|
||||
version: 0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
|
||||
'@orpc/server':
|
||||
specifier: 1.15.2
|
||||
version: 1.15.2(@opentelemetry/api@1.9.1)(ws@8.21.3)
|
||||
@@ -132,10 +129,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@discordjs/voice@0.19.2':
|
||||
resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
'@emnapi/core@2.0.0-alpha.3':
|
||||
resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==}
|
||||
|
||||
@@ -479,97 +472,6 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.12':
|
||||
resolution: {integrity: sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.12':
|
||||
resolution: {integrity: sha512-0Bwd03/JsTFhlPhF4q/LW0RxSzntFpQdhz+TBdFljYSg8IEyA38saPJeTNjpIgDfhAumPzvhCdfS6O5qT8yXDw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.12':
|
||||
resolution: {integrity: sha512-lKMV6ITi9BQLt0fx/pAT7M8xcojVK7bryVJGdaW3bq8gABFslS3ti/KzrWabQvhpEV71FZe5mV0UcKHFVaTsZw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.12':
|
||||
resolution: {integrity: sha512-vXXc/eW/e3TQeb7VsdtrPqs3/22j0aSqiP1ZXmZtDjQRBwgSxwItWYa6sh5MELP2EHB2igNlGzB6Hc0XlbGi4g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.12':
|
||||
resolution: {integrity: sha512-G1gas5HrC4Xp3mRY0+OeAqXS6fG2tRgBEc8gQh69Hw4YK9RV9mzQKcmoKMkBM72U1+2C+2u57dolnKKzwozByQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
|
||||
resolution: {integrity: sha512-97Fujh82r2Ll7dPZeNuoZ3yKfsqycf3c93OWXOo/ThNL/18Onl2Ht4SIvpX6VHhfeS9bDpHJ1lHCaz1b/i5ocw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.12':
|
||||
resolution: {integrity: sha512-FWyAOv52cHKDM4BOsmImKKogHFvqNFoXmZcicNJbX3XpVl8Mas88ZoXQ+IA5V+qc9pNtAl1MbWwEZ9JrqAQtbg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.12':
|
||||
resolution: {integrity: sha512-ptRbLSQxtV6EjXppS5z7qaPDI0NRKhrkJYsTlAjEghmOvlAObozSCYYnMO6nbkt6Ab3+lWqyahClzcRNcD2ouw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.12':
|
||||
resolution: {integrity: sha512-w86fZvhJn0ErOoAQHt2UbQ95V/cgwvfvQ4GlTPQLCzt58nn+rLlXLgPn90qYlSQrZxFW38rKXwqVOMbm9p+pwQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.12':
|
||||
resolution: {integrity: sha512-LLNnO+hfG41ymeI+O1YHo5/0h3aKaetUNLdkBwpdJsjoyKMXZaeCnB+aHNkkupJCMPmWS0g6iPMCHUOqZSBcTg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.12':
|
||||
resolution: {integrity: sha512-MPKFuqYVkDFXheR7qmtEY4FWxQ/ADfgsCojQWHi13sibUqCTR9q2F1LqNn2i9IVh3sh1sxeg87fdFMCH63pl7g==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.12':
|
||||
resolution: {integrity: sha512-Uf4OYHyfbXpzyaOqIV8/h6kv166Qni5+Bxmc1E/ov4uhhKO8qXbOky8zbVOzu0U4cv5ll3s4IQ8jDAJkx/K75Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.12':
|
||||
resolution: {integrity: sha512-nRVbKTsb2ldcPI8D4BDA7P/UeiMEMvR+wYuUMp7H1pRD/3dF2hKo+MzUU+Pn78EhuYA3CWHItiAcS/GsPld67A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.12':
|
||||
resolution: {integrity: sha512-AgUA3itPDVkxQq7RIkgE1thCiWePwWjyfOZefBBxGIlMWpRUOSwF4vY9kNLnrScyJcFULdR+Zm8PwiwV8/RKnw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey@0.1.12':
|
||||
resolution: {integrity: sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -857,9 +759,6 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
discord-api-types@0.38.52:
|
||||
resolution: {integrity: sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg==}
|
||||
|
||||
dotenv@18.0.1:
|
||||
resolution: {integrity: sha512-0eR4m4D/jH5eaI3evo2ZqqMii5mrTCR12v12VChdie6O1gPHs7XAjzTep0CWc1Bd+oNx3vVfbi734dK4zghigw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1323,23 +1222,6 @@ packages:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
prism-media@1.3.5:
|
||||
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
|
||||
peerDependencies:
|
||||
'@discordjs/opus': '>=0.8.0 <1.0.0'
|
||||
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
|
||||
node-opus: ^0.3.3
|
||||
opusscript: ^0.0.8
|
||||
peerDependenciesMeta:
|
||||
'@discordjs/opus':
|
||||
optional: true
|
||||
ffmpeg-static:
|
||||
optional: true
|
||||
node-opus:
|
||||
optional: true
|
||||
opusscript:
|
||||
optional: true
|
||||
|
||||
process-warning@5.1.0:
|
||||
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
|
||||
|
||||
@@ -1663,24 +1545,6 @@ snapshots:
|
||||
'@biomejs/cli-win32-x64@2.5.14':
|
||||
optional: true
|
||||
|
||||
'@discordjs/voice@0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
|
||||
dependencies:
|
||||
'@snazzah/davey': 0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
|
||||
'@types/ws': 8.18.1
|
||||
discord-api-types: 0.38.52
|
||||
prism-media: 1.3.5
|
||||
tslib: 2.8.1
|
||||
ws: 8.21.3
|
||||
transitivePeerDependencies:
|
||||
- '@discordjs/opus'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- bufferutil
|
||||
- ffmpeg-static
|
||||
- node-opus
|
||||
- opusscript
|
||||
- utf-8-validate
|
||||
|
||||
'@emnapi/core@2.0.0-alpha.3':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 2.0.1
|
||||
@@ -1941,73 +1805,6 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey@0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
|
||||
optionalDependencies:
|
||||
'@snazzah/davey-android-arm-eabi': 0.1.12
|
||||
'@snazzah/davey-android-arm64': 0.1.12
|
||||
'@snazzah/davey-darwin-arm64': 0.1.12
|
||||
'@snazzah/davey-darwin-x64': 0.1.12
|
||||
'@snazzah/davey-freebsd-x64': 0.1.12
|
||||
'@snazzah/davey-linux-arm-gnueabihf': 0.1.12
|
||||
'@snazzah/davey-linux-arm64-gnu': 0.1.12
|
||||
'@snazzah/davey-linux-arm64-musl': 0.1.12
|
||||
'@snazzah/davey-linux-x64-gnu': 0.1.12
|
||||
'@snazzah/davey-linux-x64-musl': 0.1.12
|
||||
'@snazzah/davey-wasm32-wasi': 0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
|
||||
'@snazzah/davey-win32-arm64-msvc': 0.1.12
|
||||
'@snazzah/davey-win32-ia32-msvc': 0.1.12
|
||||
'@snazzah/davey-win32-x64-msvc': 0.1.12
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
@@ -2233,8 +2030,6 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
discord-api-types@0.38.52: {}
|
||||
|
||||
dotenv@18.0.1: {}
|
||||
|
||||
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0):
|
||||
@@ -2618,8 +2413,6 @@ snapshots:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
prism-media@1.3.5: {}
|
||||
|
||||
process-warning@5.1.0: {}
|
||||
|
||||
prom-client@15.1.3:
|
||||
@@ -2787,7 +2580,8 @@ snapshots:
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
tsx@4.23.13:
|
||||
dependencies:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -522,6 +522,44 @@ export class MessagesRepository {
|
||||
username: r.username ? String(r.username) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct guilds present in the message archive (drives the guild picker).
|
||||
*/
|
||||
async listGuilds(): Promise<
|
||||
Array<{ id: string; name: string; icon: string | null }>
|
||||
> {
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.selectDistinct({ guild_id: pgMessagesTable.guild_id })
|
||||
.from(pgMessagesTable)
|
||||
.orderBy(pgMessagesTable.guild_id);
|
||||
return rows.map((row) => ({
|
||||
id: String(row.guild_id ?? ""),
|
||||
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
|
||||
icon: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Text channels for a guild, derived from the message archive
|
||||
* (drives the channel picker).
|
||||
*/
|
||||
async listTextChannels(
|
||||
guildId: string,
|
||||
): Promise<Array<{ id: string; name: string; type: "text" }>> {
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.selectDistinct({ channel_id: pgMessagesTable.channel_id })
|
||||
.from(pgMessagesTable)
|
||||
.where(eq(pgMessagesTable.guild_id, guildId))
|
||||
.orderBy(pgMessagesTable.channel_id);
|
||||
return rows.map((row) => ({
|
||||
id: String(row.channel_id ?? ""),
|
||||
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
|
||||
type: "text" as const,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const messagesRepository = new MessagesRepository();
|
||||
|
||||
@@ -119,6 +119,16 @@ export class MessagesService {
|
||||
logger.debug({ limit, channelId }, "Getting recent message edits");
|
||||
return messagesRepository.getRecentEdits(limit, channelId);
|
||||
}
|
||||
|
||||
/** Distinct guilds present in the message archive (guild picker). */
|
||||
async getGuilds() {
|
||||
return messagesRepository.listGuilds();
|
||||
}
|
||||
|
||||
/** Text channels for a guild (channel picker). */
|
||||
async getTextChannels(guildId: string) {
|
||||
return messagesRepository.listTextChannels(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned to the frontend (text + rich metadata from the archive payload). */
|
||||
|
||||
@@ -69,6 +69,10 @@ const dashboardRouter = {
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────────────
|
||||
const messagesRouter = {
|
||||
guilds: os.handler(() => messagesService.getGuilds()),
|
||||
textChannels: os
|
||||
.input(z.object({ guildId: z.string() }))
|
||||
.handler(({ input }) => messagesService.getTextChannels(input.guildId)),
|
||||
list: os
|
||||
.input(messageQuerySchema)
|
||||
.handler(({ input }) => messagesService.listMessages(input)),
|
||||
|
||||
Generated
+74
-280
@@ -8,15 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@discordjs/opus':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(supports-color@7.2.0)
|
||||
'@discordjs/voice':
|
||||
specifier: file:vendor/discord-voice-fork
|
||||
version: file:vendor/discord-voice-fork(@discordjs/opus@0.10.0(supports-color@7.2.0))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(opusscript@0.1.1)
|
||||
'@snazzah/davey':
|
||||
specifier: ^0.1.11
|
||||
version: 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
|
||||
'@typesafe-ai/sdk':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
@@ -25,7 +16,7 @@ importers:
|
||||
version: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
discord.js-selfbot-v13:
|
||||
specifier: ^3.7.1
|
||||
version: 3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)(supports-color@7.2.0)
|
||||
version: 3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
dotenv:
|
||||
specifier: ^18.0.0
|
||||
version: 18.0.1
|
||||
@@ -35,18 +26,12 @@ importers:
|
||||
ioredis:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(supports-color@7.2.0)
|
||||
libsodium-wrappers:
|
||||
specifier: ^0.8.4
|
||||
version: 0.8.4
|
||||
lru-cache:
|
||||
specifier: ^11.5.3
|
||||
version: 11.5.3
|
||||
openai:
|
||||
specifier: ^7.19.0
|
||||
version: 7.19.0(undici@7.29.0)(ws@8.21.3)(zod@4.6.5)
|
||||
opusscript:
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1
|
||||
p-limit:
|
||||
specifier: ^7.3.3
|
||||
version: 7.3.3
|
||||
@@ -62,9 +47,6 @@ importers:
|
||||
piscina:
|
||||
specifier: ^5.3.2
|
||||
version: 5.3.2
|
||||
prism-media:
|
||||
specifier: 2.0.0-alpha.0
|
||||
version: 2.0.0-alpha.0
|
||||
sharp:
|
||||
specifier: ^0.35.4
|
||||
version: 0.35.4(@types/node@26.4.0)
|
||||
@@ -186,19 +168,12 @@ packages:
|
||||
resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/voice@file:vendor/discord-voice-fork':
|
||||
resolution: {directory: vendor/discord-voice-fork, type: directory}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
'@drizzle-team/brocli@0.10.2':
|
||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||
|
||||
'@emnapi/core@1.11.1':
|
||||
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
|
||||
|
||||
'@emnapi/core@1.11.3':
|
||||
resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==}
|
||||
|
||||
'@emnapi/runtime@1.11.1':
|
||||
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
|
||||
|
||||
@@ -208,9 +183,6 @@ packages:
|
||||
'@emnapi/wasi-threads@1.2.2':
|
||||
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
|
||||
|
||||
'@emnapi/wasi-threads@1.2.3':
|
||||
resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==}
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||
@@ -1088,97 +1060,6 @@ packages:
|
||||
resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==}
|
||||
engines: {node: '>=v16'}
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.12':
|
||||
resolution: {integrity: sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.12':
|
||||
resolution: {integrity: sha512-0Bwd03/JsTFhlPhF4q/LW0RxSzntFpQdhz+TBdFljYSg8IEyA38saPJeTNjpIgDfhAumPzvhCdfS6O5qT8yXDw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.12':
|
||||
resolution: {integrity: sha512-lKMV6ITi9BQLt0fx/pAT7M8xcojVK7bryVJGdaW3bq8gABFslS3ti/KzrWabQvhpEV71FZe5mV0UcKHFVaTsZw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.12':
|
||||
resolution: {integrity: sha512-vXXc/eW/e3TQeb7VsdtrPqs3/22j0aSqiP1ZXmZtDjQRBwgSxwItWYa6sh5MELP2EHB2igNlGzB6Hc0XlbGi4g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.12':
|
||||
resolution: {integrity: sha512-G1gas5HrC4Xp3mRY0+OeAqXS6fG2tRgBEc8gQh69Hw4YK9RV9mzQKcmoKMkBM72U1+2C+2u57dolnKKzwozByQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
|
||||
resolution: {integrity: sha512-97Fujh82r2Ll7dPZeNuoZ3yKfsqycf3c93OWXOo/ThNL/18Onl2Ht4SIvpX6VHhfeS9bDpHJ1lHCaz1b/i5ocw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.12':
|
||||
resolution: {integrity: sha512-FWyAOv52cHKDM4BOsmImKKogHFvqNFoXmZcicNJbX3XpVl8Mas88ZoXQ+IA5V+qc9pNtAl1MbWwEZ9JrqAQtbg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.12':
|
||||
resolution: {integrity: sha512-ptRbLSQxtV6EjXppS5z7qaPDI0NRKhrkJYsTlAjEghmOvlAObozSCYYnMO6nbkt6Ab3+lWqyahClzcRNcD2ouw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.12':
|
||||
resolution: {integrity: sha512-w86fZvhJn0ErOoAQHt2UbQ95V/cgwvfvQ4GlTPQLCzt58nn+rLlXLgPn90qYlSQrZxFW38rKXwqVOMbm9p+pwQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.12':
|
||||
resolution: {integrity: sha512-LLNnO+hfG41ymeI+O1YHo5/0h3aKaetUNLdkBwpdJsjoyKMXZaeCnB+aHNkkupJCMPmWS0g6iPMCHUOqZSBcTg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.12':
|
||||
resolution: {integrity: sha512-MPKFuqYVkDFXheR7qmtEY4FWxQ/ADfgsCojQWHi13sibUqCTR9q2F1LqNn2i9IVh3sh1sxeg87fdFMCH63pl7g==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.12':
|
||||
resolution: {integrity: sha512-Uf4OYHyfbXpzyaOqIV8/h6kv166Qni5+Bxmc1E/ov4uhhKO8qXbOky8zbVOzu0U4cv5ll3s4IQ8jDAJkx/K75Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.12':
|
||||
resolution: {integrity: sha512-nRVbKTsb2ldcPI8D4BDA7P/UeiMEMvR+wYuUMp7H1pRD/3dF2hKo+MzUU+Pn78EhuYA3CWHItiAcS/GsPld67A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.12':
|
||||
resolution: {integrity: sha512-AgUA3itPDVkxQq7RIkgE1thCiWePwWjyfOZefBBxGIlMWpRUOSwF4vY9kNLnrScyJcFULdR+Zm8PwiwV8/RKnw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey@0.1.12':
|
||||
resolution: {integrity: sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||
|
||||
@@ -1601,9 +1482,6 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
duplex-child-process@1.0.1:
|
||||
resolution: {integrity: sha512-tWbt4tyioDjyK5nh+qicbdvBvNjSXsTUF5zKUwSauuKPg1mokjwn/HezwfvWhh6hXoLdgetY+ZlzU/sMwUMJkg==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
@@ -1772,12 +1650,6 @@ packages:
|
||||
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
libsodium-wrappers@0.8.4:
|
||||
resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==}
|
||||
|
||||
libsodium@0.8.4:
|
||||
resolution: {integrity: sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==}
|
||||
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -1978,9 +1850,6 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
opusscript@0.1.1:
|
||||
resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==}
|
||||
|
||||
otplib@12.0.1:
|
||||
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
||||
|
||||
@@ -2108,9 +1977,6 @@ packages:
|
||||
opusscript:
|
||||
optional: true
|
||||
|
||||
prism-media@2.0.0-alpha.0:
|
||||
resolution: {integrity: sha512-QL9rnO4xo0grgj7ptsA+AzSCYLirGWM4+ZcyboFmbkYHSgaXIESzHq/SXNizz2iHIfuM2og0cPhmSnTVMeFjKg==}
|
||||
|
||||
process-warning@5.1.0:
|
||||
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
|
||||
|
||||
@@ -2539,6 +2405,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@discordjs/opus@0.10.0(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
@@ -2547,29 +2414,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@discordjs/util@1.2.0':
|
||||
dependencies:
|
||||
discord-api-types: 0.38.52
|
||||
|
||||
'@discordjs/voice@file:vendor/discord-voice-fork(@discordjs/opus@0.10.0(supports-color@7.2.0))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(opusscript@0.1.1)':
|
||||
dependencies:
|
||||
'@snazzah/davey': 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
|
||||
'@types/ws': 8.18.1
|
||||
discord-api-types: 0.38.52
|
||||
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)
|
||||
tslib: 2.8.1
|
||||
ws: 8.21.3
|
||||
transitivePeerDependencies:
|
||||
- '@discordjs/opus'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- bufferutil
|
||||
- ffmpeg-static
|
||||
- node-opus
|
||||
- opusscript
|
||||
- utf-8-validate
|
||||
|
||||
'@drizzle-team/brocli@0.10.2': {}
|
||||
|
||||
'@emnapi/core@1.11.1':
|
||||
@@ -2578,12 +2428,6 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/core@1.11.3':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.3
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -2599,11 +2443,6 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/wasi-threads@1.2.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
dependencies:
|
||||
esbuild: 0.18.20
|
||||
@@ -3032,13 +2871,6 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.3
|
||||
'@emnapi/runtime': 1.11.3
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@otplib/core@12.0.1': {}
|
||||
|
||||
'@otplib/plugin-crypto@12.0.1':
|
||||
@@ -3124,73 +2956,6 @@ snapshots:
|
||||
fast-deep-equal: 3.1.3
|
||||
lodash: 4.18.1
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.12':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey@0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
|
||||
optionalDependencies:
|
||||
'@snazzah/davey-android-arm-eabi': 0.1.12
|
||||
'@snazzah/davey-android-arm64': 0.1.12
|
||||
'@snazzah/davey-darwin-arm64': 0.1.12
|
||||
'@snazzah/davey-darwin-x64': 0.1.12
|
||||
'@snazzah/davey-freebsd-x64': 0.1.12
|
||||
'@snazzah/davey-linux-arm-gnueabihf': 0.1.12
|
||||
'@snazzah/davey-linux-arm64-gnu': 0.1.12
|
||||
'@snazzah/davey-linux-arm64-musl': 0.1.12
|
||||
'@snazzah/davey-linux-x64-gnu': 0.1.12
|
||||
'@snazzah/davey-linux-x64-musl': 0.1.12
|
||||
'@snazzah/davey-wasm32-wasi': 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
|
||||
'@snazzah/davey-win32-arm64-msvc': 0.1.12
|
||||
'@snazzah/davey-win32-ia32-msvc': 0.1.12
|
||||
'@snazzah/davey-win32-x64-msvc': 0.1.12
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3298,7 +3063,8 @@ snapshots:
|
||||
|
||||
'@vitest/spy@5.0.1': {}
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
abbrev@1.1.1:
|
||||
optional: true
|
||||
|
||||
agent-base@6.0.2(supports-color@7.2.0):
|
||||
dependencies:
|
||||
@@ -3312,12 +3078,14 @@ snapshots:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
aproba@2.1.0: {}
|
||||
aproba@2.1.0:
|
||||
optional: true
|
||||
|
||||
are-we-there-yet@2.0.0:
|
||||
dependencies:
|
||||
delegates: 1.0.0
|
||||
readable-stream: 3.6.2
|
||||
optional: true
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
@@ -3335,7 +3103,8 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
balanced-match@1.0.2:
|
||||
optional: true
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
@@ -3343,6 +3112,7 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
optional: true
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
@@ -3365,7 +3135,8 @@ snapshots:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
chownr@2.0.0: {}
|
||||
chownr@2.0.0:
|
||||
optional: true
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
@@ -3381,7 +3152,8 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
color-support@1.1.3: {}
|
||||
color-support@1.1.3:
|
||||
optional: true
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
@@ -3389,9 +3161,11 @@ snapshots:
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
concat-map@0.0.1:
|
||||
optional: true
|
||||
|
||||
console-control-strings@1.1.0: {}
|
||||
console-control-strings@1.1.0:
|
||||
optional: true
|
||||
|
||||
debug@4.4.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
@@ -3409,7 +3183,8 @@ snapshots:
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
delegates@1.0.0: {}
|
||||
delegates@1.0.0:
|
||||
optional: true
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
@@ -3419,7 +3194,7 @@ snapshots:
|
||||
|
||||
discord-api-types@0.38.52: {}
|
||||
|
||||
discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)(supports-color@7.2.0):
|
||||
discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(supports-color@7.2.0):
|
||||
dependencies:
|
||||
'@discordjs/builders': 1.14.1
|
||||
'@discordjs/collection': 2.1.1
|
||||
@@ -3429,7 +3204,7 @@ snapshots:
|
||||
fetch-cookie: 3.2.0
|
||||
find-process: 2.1.1
|
||||
otplib: 12.0.1
|
||||
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)
|
||||
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))
|
||||
qrcode: 1.5.4
|
||||
tough-cookie: 5.1.2
|
||||
tree-kill: 1.2.2
|
||||
@@ -3465,8 +3240,6 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
duplex-child-process@1.0.1: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
@@ -3612,8 +3385,10 @@ snapshots:
|
||||
fs-minipass@2.1.0:
|
||||
dependencies:
|
||||
minipass: 3.3.6
|
||||
optional: true
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
fs.realpath@1.0.0:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
@@ -3631,6 +3406,7 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wide-align: 1.1.5
|
||||
optional: true
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
@@ -3664,6 +3440,7 @@ snapshots:
|
||||
minimatch: 3.1.5
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
optional: true
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
@@ -3675,7 +3452,8 @@ snapshots:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
has-unicode@2.0.1: {}
|
||||
has-unicode@2.0.1:
|
||||
optional: true
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
@@ -3694,8 +3472,10 @@ snapshots:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
wrappy: 1.0.2
|
||||
optional: true
|
||||
|
||||
inherits@2.0.4: {}
|
||||
inherits@2.0.4:
|
||||
optional: true
|
||||
|
||||
ioredis@6.0.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
@@ -3712,12 +3492,6 @@ snapshots:
|
||||
|
||||
is-network-error@1.3.2: {}
|
||||
|
||||
libsodium-wrappers@0.8.4:
|
||||
dependencies:
|
||||
libsodium: 0.8.4
|
||||
|
||||
libsodium@0.8.4: {}
|
||||
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
optional: true
|
||||
|
||||
@@ -3784,6 +3558,7 @@ snapshots:
|
||||
make-dir@3.1.0:
|
||||
dependencies:
|
||||
semver: 6.3.1
|
||||
optional: true
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
@@ -3801,33 +3576,41 @@ snapshots:
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.18
|
||||
optional: true
|
||||
|
||||
minipass@3.3.6:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
optional: true
|
||||
|
||||
minipass@5.0.0: {}
|
||||
minipass@5.0.0:
|
||||
optional: true
|
||||
|
||||
minizlib@2.1.2:
|
||||
dependencies:
|
||||
minipass: 3.3.6
|
||||
yallist: 4.0.0
|
||||
optional: true
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
mkdirp@1.0.4:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
|
||||
node-addon-api@8.9.0: {}
|
||||
node-addon-api@8.9.0:
|
||||
optional: true
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
optional: true
|
||||
|
||||
nopt@5.0.0:
|
||||
dependencies:
|
||||
abbrev: 1.1.1
|
||||
optional: true
|
||||
|
||||
npmlog@5.0.1:
|
||||
dependencies:
|
||||
@@ -3835,8 +3618,10 @@ snapshots:
|
||||
console-control-strings: 1.1.0
|
||||
gauge: 3.0.2
|
||||
set-blocking: 2.0.0
|
||||
optional: true
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
object-assign@4.1.1:
|
||||
optional: true
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
@@ -3845,6 +3630,7 @@ snapshots:
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
optional: true
|
||||
|
||||
openai@7.19.0(undici@7.29.0)(ws@8.21.3)(zod@4.6.5):
|
||||
optionalDependencies:
|
||||
@@ -3852,8 +3638,6 @@ snapshots:
|
||||
ws: 8.21.3
|
||||
zod: 4.6.5
|
||||
|
||||
opusscript@0.1.1: {}
|
||||
|
||||
otplib@12.0.1:
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
@@ -3880,7 +3664,8 @@ snapshots:
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
path-is-absolute@1.0.1:
|
||||
optional: true
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
optional: true
|
||||
@@ -3963,14 +3748,9 @@ snapshots:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
prism-media@1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1):
|
||||
prism-media@1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0)):
|
||||
optionalDependencies:
|
||||
'@discordjs/opus': 0.10.0(supports-color@7.2.0)
|
||||
opusscript: 0.1.1
|
||||
|
||||
prism-media@2.0.0-alpha.0:
|
||||
dependencies:
|
||||
duplex-child-process: 1.0.1
|
||||
|
||||
process-warning@5.1.0: {}
|
||||
|
||||
@@ -3989,6 +3769,7 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
optional: true
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
@@ -4005,6 +3786,7 @@ snapshots:
|
||||
rimraf@3.0.2:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
optional: true
|
||||
|
||||
rolldown@1.1.5:
|
||||
dependencies:
|
||||
@@ -4027,11 +3809,13 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.1.5
|
||||
'@rolldown/binding-win32-x64-msvc': 1.1.5
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
safe-buffer@5.2.1:
|
||||
optional: true
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
semver@6.3.1:
|
||||
optional: true
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
@@ -4074,7 +3858,8 @@ snapshots:
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@3.0.7: {}
|
||||
signal-exit@3.0.7:
|
||||
optional: true
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
dependencies:
|
||||
@@ -4106,6 +3891,7 @@ snapshots:
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
optional: true
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
@@ -4123,6 +3909,7 @@ snapshots:
|
||||
minizlib: 2.1.2
|
||||
mkdirp: 1.0.4
|
||||
yallist: 4.0.0
|
||||
optional: true
|
||||
|
||||
thirty-two@1.0.2: {}
|
||||
|
||||
@@ -4161,7 +3948,8 @@ snapshots:
|
||||
dependencies:
|
||||
tldts: 7.4.9
|
||||
|
||||
tr46@0.0.3: {}
|
||||
tr46@0.0.3:
|
||||
optional: true
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
@@ -4202,7 +3990,8 @@ snapshots:
|
||||
|
||||
undici@7.29.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
util-deprecate@1.0.2:
|
||||
optional: true
|
||||
|
||||
vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13):
|
||||
dependencies:
|
||||
@@ -4238,7 +4027,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
webidl-conversions@3.0.1:
|
||||
optional: true
|
||||
|
||||
werift-rtp@0.8.9(supports-color@7.2.0):
|
||||
dependencies:
|
||||
@@ -4252,6 +4042,7 @@ snapshots:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
optional: true
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
@@ -4263,6 +4054,7 @@ snapshots:
|
||||
wide-align@1.1.5:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
optional: true
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
@@ -4270,7 +4062,8 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
wrappy@1.0.2:
|
||||
optional: true
|
||||
|
||||
ws@8.21.3: {}
|
||||
|
||||
@@ -4278,7 +4071,8 @@ snapshots:
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yallist@4.0.0: {}
|
||||
yallist@4.0.0:
|
||||
optional: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
AudioWaveform,
|
||||
ChevronRight,
|
||||
Hash,
|
||||
MessageSquare,
|
||||
@@ -422,16 +421,6 @@ export function DashboardView({
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 text-ink-muted" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/voice"
|
||||
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<AudioWaveform className="size-3.5 text-success" />
|
||||
Active Voice Stages
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 text-ink-muted" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/analysis"
|
||||
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
|
||||
@@ -442,16 +431,6 @@ export function DashboardView({
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 text-ink-muted" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/recordings"
|
||||
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Mic className="size-3.5 text-vermilion" />
|
||||
Voice Recording Archive
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 text-ink-muted" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PageTransition>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getMediaStatus } from "@/lib/api/server";
|
||||
import { MediaView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MediaPage() {
|
||||
let status: import("@/lib/types").MediaState | undefined;
|
||||
try {
|
||||
status = await getMediaStatus();
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <MediaView initialStatus={status} />;
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Disc, Play, Repeat, SkipForward, Square } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Equalizer } from "@/components/charts";
|
||||
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
|
||||
import {
|
||||
ErrorState,
|
||||
PageTransition,
|
||||
SectionHeader,
|
||||
SkeletonHero,
|
||||
SkeletonPanel,
|
||||
} from "@/components/shared";
|
||||
import {
|
||||
useMediaLoop,
|
||||
useMediaQueue,
|
||||
useMediaSkip,
|
||||
useMediaState,
|
||||
useMediaStop,
|
||||
useMediaWsSync,
|
||||
} from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
import type { MediaState } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
const ws = useWebSocket();
|
||||
const {
|
||||
data: media,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
} = useMediaState(initialStatus);
|
||||
const queue = useMediaQueue();
|
||||
const skip = useMediaSkip();
|
||||
const stop = useMediaStop();
|
||||
const loop = useMediaLoop();
|
||||
useMediaWsSync(ws);
|
||||
const ambient = useAmbient();
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
const playing = media?.playing ?? false;
|
||||
const current = media?.current ?? null;
|
||||
const queueList = media?.queue ?? [];
|
||||
|
||||
const mediaRef = useStaggerReveal<HTMLDivElement>(".media-tile", {
|
||||
stagger: 0.04,
|
||||
y: 8,
|
||||
dependencies: [playing, queueList.length],
|
||||
});
|
||||
|
||||
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
tone,
|
||||
playing ? 0.45 : 0.2,
|
||||
playing ? "playing stream" : "media ready",
|
||||
);
|
||||
}, [tone, playing, ambient]);
|
||||
|
||||
const onPlay = async () => {
|
||||
const u = url.trim();
|
||||
if (!u) {
|
||||
toast({ title: "Enter media URL", tone: "vermilion" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await queue.mutateAsync({ url: u, mode: "music" });
|
||||
setUrl("");
|
||||
toast({ title: "Track queued", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Queue failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !media)
|
||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||
if (!media && isLoading)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SkeletonHero />
|
||||
<SkeletonPanel rows={3} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const mockLevels = playing
|
||||
? [0.4, 0.7, 0.9, 0.6, 0.8, 0.5, 0.9, 0.7, 0.4, 0.8, 0.6, 0.9]
|
||||
: [0.08, 0.08, 0.08, 0.08, 0.08, 0.08];
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<div ref={mediaRef} className="space-y-4">
|
||||
{/* Precision Sub-Header Bar */}
|
||||
<div className="media-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
playing
|
||||
? "bg-signal shadow-[0_0_8px_var(--color-signal-glow)]"
|
||||
: "bg-ink-muted"
|
||||
}`}
|
||||
/>
|
||||
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||
Audio Engine · Media Gateway
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||
<span className="text-ink-muted">ENGINE:</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 font-medium border ${
|
||||
playing
|
||||
? "border-signal/30 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-surface-2 text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{playing ? "STREAMING" : "IDLE"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Playback Deck */}
|
||||
<div className="media-tile">
|
||||
<GlassPanel className="p-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="flex size-11 items-center justify-center rounded-[8px] border border-hairline bg-surface-2 text-signal">
|
||||
<Disc
|
||||
className={`size-6 ${playing ? "animate-spin-disc text-signal" : "text-ink-muted"}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold tracking-tight text-ink">
|
||||
{current?.title ?? "No Active Media Stream"}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-ink-muted">
|
||||
{current
|
||||
? `${formatDuration(current.durationMs ?? 0)} · High Fidelity Stream`
|
||||
: "Queue a track via URL below"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => loop.mutateAsync()}
|
||||
className={media?.loop ? "border-signal text-signal" : ""}
|
||||
>
|
||||
<Repeat className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skip.mutateAsync()}
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => stop.mutateAsync()}
|
||||
>
|
||||
<Square className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick URL Input */}
|
||||
<div className="mt-4 flex gap-2 border-t border-hairline pt-4">
|
||||
<Input
|
||||
placeholder="Paste YouTube, SoundCloud, or Direct Stream URL..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button variant="primary" size="md" onClick={onPlay}>
|
||||
<Play className="size-3.5" />
|
||||
Queue Track
|
||||
</Button>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Spectrum & Queue Grid */}
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<GlassPanel className="media-tile lg:col-span-2">
|
||||
<SectionHeader
|
||||
eyebrow="Visualizer"
|
||||
title="Realtime Frequency Array"
|
||||
/>
|
||||
<div className="my-6 flex items-center justify-center">
|
||||
<Equalizer bars={mockLevels} />
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="media-tile">
|
||||
<SectionHeader
|
||||
eyebrow="Playlist"
|
||||
title={`Queue (${queueList.length})`}
|
||||
/>
|
||||
<div className="mt-3 space-y-2">
|
||||
{queueList.length === 0 ? (
|
||||
<div className="py-8 text-center font-mono text-xs text-ink-muted">
|
||||
QUEUE EMPTY
|
||||
</div>
|
||||
) : (
|
||||
queueList.map((item, idx) => (
|
||||
<div
|
||||
key={item.id ?? idx}
|
||||
className="flex items-center justify-between rounded-[6px] border border-hairline bg-surface-2 p-2.5 text-xs text-ink-soft"
|
||||
>
|
||||
<span className="truncate pr-2 font-medium">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-ink-muted shrink-0">
|
||||
{formatDuration(item.durationMs ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
@@ -263,7 +263,6 @@ export function MessagesView({
|
||||
{/* Filter and Mode Bar */}
|
||||
<GlassPanel className="flex flex-wrap items-center gap-3 p-3">
|
||||
<GuildChannelPicker
|
||||
mode="text"
|
||||
guildsInitial={initialGuilds}
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { getRecordings } from "@/lib/api/server";
|
||||
import type { PaginatedRecordings } from "@/lib/types";
|
||||
import { RecordingsView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RecordingsPage() {
|
||||
let recordings: PaginatedRecordings | undefined;
|
||||
try {
|
||||
recordings = await getRecordings(50);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <RecordingsView initialPage={recordings} />;
|
||||
}
|
||||
@@ -1,989 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Download,
|
||||
FileAudio,
|
||||
Files,
|
||||
Hash,
|
||||
Headphones,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
Mic,
|
||||
Pause,
|
||||
Play,
|
||||
Search,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
GlassCard,
|
||||
GlassPanel,
|
||||
Input,
|
||||
Select,
|
||||
type SelectOption,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from "@/components/primitives";
|
||||
import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
|
||||
import {
|
||||
NowPlayingChip,
|
||||
RecordingAudioPlayer,
|
||||
} from "@/components/voice/recording-audio-player";
|
||||
import {
|
||||
type RecordingsFilter,
|
||||
useDeleteRecording,
|
||||
useLoadMoreRecordings,
|
||||
useRecordings,
|
||||
useRecordingsSummary,
|
||||
useRecordingsWsSync,
|
||||
} from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import {
|
||||
audioBufferToWav,
|
||||
concatAudioBuffers,
|
||||
decodeAudio,
|
||||
downloadBlob,
|
||||
} from "@/lib/audio/wav";
|
||||
import { formatBytes, formatDuration, formatRelativeTime } from "@/lib/format";
|
||||
import type {
|
||||
PaginatedRecordings,
|
||||
SpeakerSummary,
|
||||
VoiceRecording,
|
||||
} from "@/lib/types";
|
||||
import { isVideoRecording } from "@/lib/types/recording";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
type Tab = "deck" | "leaderboard";
|
||||
|
||||
export function RecordingsView({
|
||||
initialPage,
|
||||
}: {
|
||||
initialPage?: PaginatedRecordings;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const [tab, setTab] = useState<Tab>("deck");
|
||||
const [q, setQ] = useState("");
|
||||
const [channelId, setChannelId] = useState(ALL);
|
||||
const [userId, setUserId] = useState(ALL);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
|
||||
const activeFilter: RecordingsFilter = useMemo(() => {
|
||||
const f: RecordingsFilter = {};
|
||||
if (channelId !== ALL) f.channelId = channelId;
|
||||
if (userId !== ALL) f.userId = userId;
|
||||
const sq = q.trim();
|
||||
if (sq) f.q = sq;
|
||||
if (startDate) f.startDate = new Date(`${startDate}T00:00:00`).getTime();
|
||||
if (endDate) f.endDate = new Date(`${endDate}T23:59:59`).getTime();
|
||||
return f;
|
||||
}, [channelId, userId, q, startDate, endDate]);
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isLoading,
|
||||
error,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
mutate,
|
||||
} = useRecordings(initialPage, activeFilter);
|
||||
const loadMore = useLoadMoreRecordings(activeFilter);
|
||||
const del = useDeleteRecording();
|
||||
const summary = useRecordingsSummary();
|
||||
useRecordingsWsSync(ws);
|
||||
const ambient = useAmbient();
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [exportingIds, setExportingIds] = useState<Set<string>>(new Set());
|
||||
const [exportingAll, setExportingAll] = useState(false);
|
||||
const [exportingSessions, setExportingSessions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Maximum older pages to prevent infinite runaway memory usage
|
||||
const MAX_OLDER_PAGES = 10;
|
||||
const [loadedPages, setLoadedPages] = useState(0);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const deckRef = useStaggerReveal<HTMLDivElement>(".recording-deck-card", {
|
||||
stagger: 0.04,
|
||||
y: 10,
|
||||
dependencies: [items?.length === 0],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
ambient.set("signal", 0.3, "recordings");
|
||||
}, [ambient]);
|
||||
|
||||
// Distinct speakers/channels across the current (filtered) list.
|
||||
const userOptions = useMemo<SelectOption[]>(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const r of items ?? []) {
|
||||
if (r.user_id && !map.has(r.user_id)) map.set(r.user_id, r.username);
|
||||
}
|
||||
return [
|
||||
{ value: ALL, label: "All speakers" },
|
||||
...[...map.entries()].map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
}, [items]);
|
||||
|
||||
const channelOptions = useMemo<SelectOption[]>(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const r of items ?? []) {
|
||||
if (r.channel_id && !map.has(r.channel_id))
|
||||
map.set(r.channel_id, r.channel_name ?? r.channel_id);
|
||||
}
|
||||
return [
|
||||
{ value: ALL, label: "All channels" },
|
||||
...[...map.entries()].map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
}, [items]);
|
||||
|
||||
const sessions = useMemo(() => groupSessions(items ?? []), [items]);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (
|
||||
!hasMore ||
|
||||
!nextCursor ||
|
||||
loadMore.isPending ||
|
||||
loadedPages >= MAX_OLDER_PAGES
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await loadMore.mutateAsync({ cursor: nextCursor });
|
||||
setLoadedPages((n) => n + 1);
|
||||
} catch {
|
||||
// client error handling in hook/action
|
||||
}
|
||||
}, [hasMore, nextCursor, loadMore, loadedPages]);
|
||||
|
||||
// Infinite scroll trigger via IntersectionObserver on sentinel at the bottom of the list
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
void loadOlder();
|
||||
}
|
||||
},
|
||||
{ root: scrollRef.current, rootMargin: "200px" },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [loadOlder]);
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
try {
|
||||
await del.mutateAsync(id);
|
||||
toast({ title: "Recording deleted", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Delete failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onExportOne = async (r: VoiceRecording) => {
|
||||
if (!r.download_url) return;
|
||||
if (exportingIds.has(r.id)) return;
|
||||
setExportingIds((prev) => new Set(prev).add(r.id));
|
||||
try {
|
||||
const buf = await decodeAudio(r.download_url);
|
||||
const wav = audioBufferToWav(buf);
|
||||
const safeName = (r.username ?? "speaker").replace(/[^\w.-]+/g, "_");
|
||||
downloadBlob(wav, `gmw-rec-${safeName}-${r.id.slice(0, 8)}.wav`);
|
||||
toast({ title: "WAV exported (Audacity-ready)", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Export failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
} finally {
|
||||
setExportingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(r.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onExportAll = async () => {
|
||||
if (exportingAll) return;
|
||||
const target = (items ?? []).filter(
|
||||
(r): r is VoiceRecording & { download_url: string } =>
|
||||
Boolean(r.download_url),
|
||||
);
|
||||
if (target.length === 0) return;
|
||||
setExportingAll(true);
|
||||
try {
|
||||
const buffers = [];
|
||||
for (const r of target) {
|
||||
buffers.push(await decodeAudio(r.download_url));
|
||||
}
|
||||
const merged = concatAudioBuffers(buffers);
|
||||
if (!merged) throw new Error("No audio decoded");
|
||||
const wav = audioBufferToWav(merged);
|
||||
const who =
|
||||
userId !== ALL
|
||||
? (target[0]?.username ?? "speaker").replace(/[^\w.-]+/g, "_")
|
||||
: "all";
|
||||
downloadBlob(wav, `gmw-rec-${who}-${target.length}clips.wav`);
|
||||
toast({
|
||||
title: `Exported ${target.length} clips as one WAV`,
|
||||
tone: "signal",
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Export failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
} finally {
|
||||
setExportingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onExportSession = async (session: Session) => {
|
||||
if (exportingSessions.has(session.key)) return;
|
||||
const target = session.clips.filter(
|
||||
(r): r is VoiceRecording & { download_url: string } =>
|
||||
Boolean(r.download_url),
|
||||
);
|
||||
if (target.length === 0) return;
|
||||
setExportingSessions((prev) => new Set(prev).add(session.key));
|
||||
try {
|
||||
const buffers = [];
|
||||
for (const r of target) {
|
||||
buffers.push(await decodeAudio(r.download_url));
|
||||
}
|
||||
const merged = concatAudioBuffers(buffers);
|
||||
if (!merged) throw new Error("No audio decoded");
|
||||
const wav = audioBufferToWav(merged);
|
||||
const safeChannel = (session.channelName ?? "session").replace(
|
||||
/[^\w.-]+/g,
|
||||
"_",
|
||||
);
|
||||
downloadBlob(wav, `gmw-session-${safeChannel}-${target.length}clips.wav`);
|
||||
toast({
|
||||
title: `Session exported: ${target.length} clips as one WAV`,
|
||||
tone: "signal",
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Session export failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
} finally {
|
||||
setExportingSessions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(session.key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !items)
|
||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||
if (!items && isLoading)
|
||||
return (
|
||||
<GlassPanel>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<GlassCard key={i} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-2.5 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
);
|
||||
|
||||
const totalRecordings = (items ?? []).length;
|
||||
const exportableCount = (items ?? []).filter((r) => r.download_url).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tactical HUD Header Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
|
||||
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||
Tape Deck · Captured Audio Archive
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TabButton active={tab === "deck"} onClick={() => setTab("deck")}>
|
||||
<MessagesSquare className="size-3.5" /> DECK
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={tab === "leaderboard"}
|
||||
onClick={() => setTab("leaderboard")}
|
||||
>
|
||||
<Users className="size-3.5" /> LEADERBOARD
|
||||
</TabButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
|
||||
<span>STATUS:</span>
|
||||
<span
|
||||
className="glitch-text rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30"
|
||||
data-text={`${totalRecordings} CLIPS_LOADED`}
|
||||
>
|
||||
{totalRecordings} CLIPS_LOADED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "leaderboard" ? (
|
||||
<Leaderboard
|
||||
summary={summary.data}
|
||||
isLoading={summary.isLoading}
|
||||
onShowSpeaker={(uid) => {
|
||||
setUserId(uid);
|
||||
setTab("deck");
|
||||
setLoadedPages(0);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Filter + Search + Export toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="relative min-w-0 flex-1 basis-56">
|
||||
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-ink-faint" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search transcriptions / speakers…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={userId}
|
||||
onChange={(v) => {
|
||||
setUserId(v);
|
||||
setLoadedPages(0);
|
||||
}}
|
||||
options={userOptions}
|
||||
placeholder="Speaker"
|
||||
size="sm"
|
||||
className="w-40"
|
||||
/>
|
||||
<Select
|
||||
value={channelId}
|
||||
onChange={(v) => {
|
||||
setChannelId(v);
|
||||
setLoadedPages(0);
|
||||
}}
|
||||
options={channelOptions}
|
||||
placeholder="Channel"
|
||||
size="sm"
|
||||
className="w-40"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
aria-label="From date"
|
||||
className="w-36"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
aria-label="To date"
|
||||
className="w-36"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onExportAll}
|
||||
disabled={exportingAll || exportableCount === 0}
|
||||
title="Concatenate the visible (filtered) recordings into one WAV for Audacity"
|
||||
>
|
||||
{exportingAll ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Files className="size-3.5" />
|
||||
)}
|
||||
{exportingAll
|
||||
? "RENDERING..."
|
||||
: `EXPORT WAV (${exportableCount})`}
|
||||
</Button>
|
||||
{(Object.keys(activeFilter).length > 0 ||
|
||||
channelId !== ALL ||
|
||||
userId !== ALL) && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setQ("");
|
||||
setChannelId(ALL);
|
||||
setUserId(ALL);
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setLoadedPages(0);
|
||||
}}
|
||||
>
|
||||
RESET
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="acoustic buffer"
|
||||
title="Voice Capture Tape Deck"
|
||||
action={
|
||||
<span className="mono text-xs text-[#8a8f98]">
|
||||
{totalRecordings} clips loaded{" "}
|
||||
{hasMore ? "· more available" : ""}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{totalRecordings === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Headphones className="size-7" />}
|
||||
title={
|
||||
Object.keys(activeFilter).length > 0
|
||||
? "No recordings match these filters"
|
||||
: "Tape deck is empty"
|
||||
}
|
||||
description={
|
||||
Object.keys(activeFilter).length > 0
|
||||
? "Try clearing the search or filters, or pick a different speaker/channel."
|
||||
: "Voice transmissions captured in connected channels will be archived here."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="mt-4 max-h-[calc(100vh-260px)] overflow-y-auto pr-1"
|
||||
>
|
||||
<div
|
||||
ref={deckRef}
|
||||
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
{sessions.flatMap((session) => {
|
||||
const sessionPlayable = session.clips.some((c) =>
|
||||
Boolean(c.download_url),
|
||||
);
|
||||
const sessionExporting = exportingSessions.has(session.key);
|
||||
const header = (
|
||||
<SessionHeader
|
||||
key={`session-${session.key}`}
|
||||
session={session}
|
||||
playable={sessionPlayable}
|
||||
exporting={sessionExporting}
|
||||
onExport={() => void onExportSession(session)}
|
||||
/>
|
||||
);
|
||||
const cards = session.clips.map((r) => {
|
||||
const up = uploadStatus(r);
|
||||
const isPlaying = playingId === r.id;
|
||||
const isExporting = exportingIds.has(r.id);
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`recording-deck-card hud-card flex flex-col justify-between p-4 transition-all duration-200 ${
|
||||
isPlaying
|
||||
? "border-signal/50 bg-signal/10 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
{/* Header info */}
|
||||
<div className="flex items-center gap-3 border-b border-hairline pb-3">
|
||||
<Avatar
|
||||
src={r.avatar_url}
|
||||
name={r.username}
|
||||
size={36}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold text-ink">
|
||||
{r.username}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-mono text-[10px] text-ink-faint">
|
||||
<Hash className="size-2.5 text-signal" />
|
||||
<span className="truncate">
|
||||
{r.channel_name ?? "voice"}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span suppressHydrationWarning>
|
||||
{formatRelativeTime(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isPlaying && <NowPlayingChip />}
|
||||
{up && !isPlaying && (
|
||||
<Badge
|
||||
tone={up.tone}
|
||||
className="font-mono text-[9px]"
|
||||
>
|
||||
{up.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transcription snippet */}
|
||||
<div className="py-2">
|
||||
<TranscriptionSnippet text={r.transcription} />
|
||||
</div>
|
||||
|
||||
{/* Audio Player Scrub */}
|
||||
<div className="my-1">
|
||||
{r.download_url ? (
|
||||
<RecordingAudioPlayer
|
||||
src={r.download_url}
|
||||
label={`${
|
||||
isVideoRecording(r) ? "Video" : "Voice"
|
||||
} recording by ${r.username}`}
|
||||
video={isVideoRecording(r)}
|
||||
onPlayStateChange={(active) =>
|
||||
setPlayingId((prev) => {
|
||||
if (active) return r.id;
|
||||
return prev === r.id ? null : prev;
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 px-3 py-2 font-mono text-[11px] text-ink-muted">
|
||||
<Loader2 className="size-3.5 animate-spin text-signal" />
|
||||
{r.upload_status === "pending"
|
||||
? "UPLOAD_PENDING..."
|
||||
: r.upload_error
|
||||
? r.upload_error
|
||||
: "SYNTHESIZING_PCM..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions & File Stats */}
|
||||
<div className="flex items-center justify-between border-t border-hairline pt-2.5">
|
||||
<span className="font-mono text-[10px] text-ink-faint">
|
||||
SIZE: {formatBytes(r.size_bytes)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.download_url && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExportOne(r)}
|
||||
disabled={isExporting}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-signal/40 bg-signal/10 px-2 py-1 font-mono text-[10px] text-signal transition-colors hover:bg-signal/20"
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<FileAudio className="size-3" />
|
||||
)}
|
||||
{isExporting ? "WAV..." : "WAV"}
|
||||
</button>
|
||||
<a
|
||||
href={r.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-hairline bg-surface-2 px-2 py-1 font-mono text-[10px] text-ink-soft transition-colors hover:bg-surface hover:text-ink"
|
||||
>
|
||||
<Download className="size-3 text-signal" />{" "}
|
||||
RAW
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(r.id)}
|
||||
disabled={del.isPending}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-vermilion/30 bg-vermilion/10 px-2 py-1 font-mono text-[10px] text-vermilion transition-colors hover:bg-vermilion/20"
|
||||
>
|
||||
<Trash2 className="size-3" /> PURGE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return [header, ...cards];
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Infinite scroll sentinel & status footer */}
|
||||
<div ref={sentinelRef} className="py-4 text-center">
|
||||
{loadMore.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2 font-mono text-xs text-ink-muted">
|
||||
<Loader2 className="size-4 animate-spin text-signal" />
|
||||
LOADING EARLIER RECORDINGS...
|
||||
<span className="typing-dots">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</span>
|
||||
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadOlder}
|
||||
className="rounded-md border border-hairline bg-surface-2 px-3 py-1.5 font-mono text-xs text-ink-muted transition-colors hover:bg-surface hover:text-ink"
|
||||
>
|
||||
↓ LOAD MORE RECORDINGS
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-[10px] text-ink-faint">
|
||||
{loadedPages >= MAX_OLDER_PAGES
|
||||
? `CAPPED AT ${MAX_OLDER_PAGES} PAGES`
|
||||
: "ARCHIVE END REACHED"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 font-mono text-[10px] transition-colors ${
|
||||
active
|
||||
? "border-signal/40 bg-signal/15 text-signal"
|
||||
: "border-hairline bg-surface-2 text-ink-soft hover:bg-surface hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptionSnippet({ text }: { text?: string | null }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!text) {
|
||||
return (
|
||||
<div className="h-10 rounded-[6px] border border-dashed border-hairline bg-surface-2/40 px-3 py-2 font-mono text-[10px] text-ink-faint">
|
||||
no transcription
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const collapsed = !open && text.length > 160;
|
||||
return (
|
||||
<div className="rounded-[6px] border border-hairline bg-surface-2 px-3 py-2">
|
||||
<p
|
||||
className={`font-mono text-[11px] leading-relaxed text-ink-soft ${
|
||||
collapsed ? "line-clamp-3" : ""
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="mt-1 font-mono text-[10px] text-signal hover:underline"
|
||||
>
|
||||
READ FULL TRANSCRIPT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Leaderboard({
|
||||
summary,
|
||||
isLoading,
|
||||
onShowSpeaker,
|
||||
}: {
|
||||
summary: SpeakerSummary[] | undefined;
|
||||
isLoading: boolean;
|
||||
onShowSpeaker: (userId: string, username: string) => void;
|
||||
}) {
|
||||
if (isLoading && !summary)
|
||||
return (
|
||||
<GlassPanel>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="mt-3 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
);
|
||||
if (!summary || summary.length === 0)
|
||||
return (
|
||||
<GlassPanel>
|
||||
<EmptyState
|
||||
icon={<Mic className="size-7" />}
|
||||
title="No speakers yet"
|
||||
description="Leaderboard populates as voice is captured and transcribed."
|
||||
/>
|
||||
</GlassPanel>
|
||||
);
|
||||
|
||||
const maxClips = Math.max(...summary.map((s) => s.clips), 1);
|
||||
|
||||
return (
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="speech analytics"
|
||||
title="Speaker Leaderboard"
|
||||
action={
|
||||
<span className="mono text-xs text-[#8a8f98]">
|
||||
{summary.length} speakers
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="mt-4 space-y-1.5">
|
||||
{summary.map((s, idx) => (
|
||||
<button
|
||||
key={s.user_id}
|
||||
type="button"
|
||||
onClick={() => onShowSpeaker(s.user_id, s.username)}
|
||||
className="group flex w-full items-center gap-3 rounded-lg border border-hairline bg-surface-2/50 px-3 py-2.5 text-left transition-colors hover:border-signal/40 hover:bg-surface"
|
||||
>
|
||||
<span className="w-6 shrink-0 text-center font-mono text-xs text-ink-faint">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<Avatar src={s.avatar_url} name={s.username} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold text-ink">
|
||||
{s.username}
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-surface">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal"
|
||||
style={{ width: `${(s.clips / maxClips) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right font-mono text-[10px] text-ink-soft">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Mic className="size-3 text-signal" /> {s.clips}
|
||||
</div>
|
||||
<div>{formatDuration(s.est_duration_s * 1000)}</div>
|
||||
<div className="text-ink-faint">
|
||||
{s.transcribed > 0 ? `${s.words} words` : "no transcript"}
|
||||
</div>
|
||||
<div className="text-ink-faint" suppressHydrationWarning>
|
||||
{s.last_at ? `active ${formatRelativeTime(s.last_at)}` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function uploadStatus(
|
||||
r: VoiceRecording,
|
||||
): { tone: "neutral" | "amber" | "vermilion"; label: string } | null {
|
||||
if (r.download_url) return null;
|
||||
if (r.upload_status === "error" || r.upload_error)
|
||||
return { tone: "vermilion", label: "failed" };
|
||||
if (r.upload_status === "pending") return { tone: "amber", label: "pending" };
|
||||
return { tone: "amber", label: "processing" };
|
||||
}
|
||||
|
||||
// ── Session grouping ──────────────────────────────────────────────────────
|
||||
// Clips become one "meeting session" when they share a channel and the gap
|
||||
// between consecutive clips is within SESSION_GAP_MS. items arrive newest-first,
|
||||
// so sessions are produced most-recent-first and each keeps its clips in that
|
||||
// order (newest → oldest).
|
||||
const SESSION_GAP_MS = 120_000;
|
||||
|
||||
interface Session {
|
||||
key: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTs: number;
|
||||
endTs: number;
|
||||
clips: VoiceRecording[];
|
||||
}
|
||||
|
||||
function groupSessions(items: VoiceRecording[]): Session[] {
|
||||
const sessions: Session[] = [];
|
||||
for (const r of items) {
|
||||
const last = sessions[sessions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.channelId === (r.channel_id ?? "") &&
|
||||
last.startTs - r.created_at <= SESSION_GAP_MS
|
||||
) {
|
||||
last.clips.push(r);
|
||||
last.startTs = r.created_at;
|
||||
} else {
|
||||
sessions.push({
|
||||
key: r.id,
|
||||
channelId: r.channel_id ?? "",
|
||||
channelName: r.channel_name ?? "voice",
|
||||
startTs: r.created_at,
|
||||
endTs: r.created_at,
|
||||
clips: [r],
|
||||
});
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function sessionEstDurationSec(session: Session): number {
|
||||
const bytes = session.clips.reduce((acc, c) => acc + c.size_bytes, 0);
|
||||
return Math.round((bytes * 8) / 128000);
|
||||
}
|
||||
|
||||
function sessionTimestamp(session: Session): string {
|
||||
const d = new Date(session.endTs);
|
||||
const s = new Date(session.startTs);
|
||||
const sameDay = d.toDateString() === s.toDateString();
|
||||
const fmt = (x: Date) =>
|
||||
`${x.getMonth() + 1}/${x.getDate()} ${String(x.getHours()).padStart(2, "0")}:${String(
|
||||
x.getMinutes(),
|
||||
).padStart(2, "0")}`;
|
||||
return sameDay ? fmt(d) : `${fmt(d)} – ${fmt(s)}`;
|
||||
}
|
||||
|
||||
/** Sequential playback of a session's clips via one <audio> element. */
|
||||
function SessionPlayer({
|
||||
clips,
|
||||
}: {
|
||||
clips: Array<VoiceRecording & { download_url: string }>;
|
||||
}) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio();
|
||||
audio.preload = "metadata";
|
||||
audioRef.current = audio;
|
||||
const onEnded = () => {
|
||||
setIdx((i) => {
|
||||
const next = i + 1;
|
||||
if (next < clips.length) {
|
||||
audio.src = clips[next].download_url;
|
||||
void audio.play().catch(() => setPlaying(false));
|
||||
return next;
|
||||
}
|
||||
setPlaying(false);
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
audio.addEventListener("ended", onEnded);
|
||||
return () => {
|
||||
audio.removeEventListener("ended", onEnded);
|
||||
audio.pause();
|
||||
audio.src = "";
|
||||
};
|
||||
}, [clips]);
|
||||
|
||||
const toggle = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
setPlaying(false);
|
||||
} else {
|
||||
if (idx >= clips.length || audio.src === "")
|
||||
audio.src = clips[0].download_url;
|
||||
void audio.play().catch(() => setPlaying(false));
|
||||
setPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title={
|
||||
playing ? "Pause session playback" : "Play all clips in this session"
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-signal/40 bg-signal/10 px-2 py-1 font-mono text-[10px] text-signal transition-colors hover:bg-signal/20"
|
||||
>
|
||||
{playing ? <Pause className="size-3" /> : <Play className="size-3" />}
|
||||
{playing ? `PAUSE ${idx + 1}/${clips.length}` : "PLAY SESSION"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionHeader({
|
||||
session,
|
||||
playable,
|
||||
exporting,
|
||||
onExport,
|
||||
}: {
|
||||
session: Session;
|
||||
playable: boolean;
|
||||
exporting: boolean;
|
||||
onExport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="col-span-full -mx-1 mb-1 mt-2 flex items-center gap-3 rounded-lg border border-hairline bg-surface px-3 py-2 first:mt-0">
|
||||
<Hash className="size-3.5 shrink-0 text-signal" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-[11px] font-semibold uppercase tracking-wide text-ink">
|
||||
{session.channelName}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-ink-faint">
|
||||
{sessionTimestamp(session)} · {session.clips.length} clips ·{" "}
|
||||
{formatDuration(sessionEstDurationSec(session) * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
{playable && (
|
||||
<SessionPlayer
|
||||
clips={session.clips.filter(
|
||||
(c): c is VoiceRecording & { download_url: string } =>
|
||||
Boolean(c.download_url),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExport}
|
||||
disabled={exporting || !playable}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-hairline bg-surface-2 px-2 py-1 font-mono text-[10px] text-ink-soft transition-colors hover:bg-surface hover:text-ink"
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Files className="size-3" />
|
||||
)}
|
||||
{exporting ? "WAV..." : "EXPORT SESSION"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { getGuilds, getVoiceStatus } from "@/lib/api/server";
|
||||
import { VoiceView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function VoicePage() {
|
||||
let status: import("@/lib/types").VoiceStatus | undefined;
|
||||
let guilds: import("@/lib/types").Guild[] | undefined;
|
||||
try {
|
||||
[status, guilds] = await Promise.all([getVoiceStatus(), getGuilds()]);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <VoiceView initialStatus={status} initialGuilds={guilds} />;
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Cable,
|
||||
Mic,
|
||||
PhoneOff,
|
||||
Radio,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Button, GlassPanel, toast } from "@/components/primitives";
|
||||
import {
|
||||
ErrorState,
|
||||
PageTransition,
|
||||
SectionHeader,
|
||||
SkeletonPanel,
|
||||
} from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import { VoiceStage } from "@/components/voice/voice-stage";
|
||||
import {
|
||||
useMicTransmit,
|
||||
useSpeakers,
|
||||
useVoiceConnect,
|
||||
useVoiceDisconnect,
|
||||
useVoiceListen,
|
||||
useVoiceStatus,
|
||||
} from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import type { Guild, VoiceStatus } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function VoiceView({
|
||||
initialStatus,
|
||||
initialGuilds,
|
||||
}: {
|
||||
initialStatus?: VoiceStatus;
|
||||
initialGuilds?: Guild[];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const {
|
||||
data: status,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
} = useVoiceStatus(initialStatus);
|
||||
const connect = useVoiceConnect();
|
||||
const disconnect = useVoiceDisconnect();
|
||||
const mic = useMicTransmit(ws);
|
||||
const listen = useVoiceListen(ws);
|
||||
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
||||
const ambient = useAmbient();
|
||||
|
||||
const [guildId, setGuildId] = useState<string | null>(
|
||||
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||
);
|
||||
const [channelId, setChannelId] = useState<string | null>(
|
||||
initialStatus?.activeChannelId ?? null,
|
||||
);
|
||||
const [micVol, setMicVol] = useState(100);
|
||||
const [listenVol, setListenVol] = useState(75);
|
||||
const [micActive, setMicActive] = useState(false);
|
||||
const [listenActive, setListenActive] = useState(false);
|
||||
|
||||
const toggleMic = async () => {
|
||||
const next = !micActive;
|
||||
try {
|
||||
await mic.mutateAsync(next);
|
||||
setMicActive(next);
|
||||
toast({
|
||||
title: next ? "Mic activated" : "Mic deactivated",
|
||||
tone: next ? "signal" : "neutral",
|
||||
});
|
||||
} catch (e) {
|
||||
// MicAccessError from mic-transmit.ts has specific reasons
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const isPermDenied = msg.includes("denied") || msg.includes("Permission");
|
||||
const isNoMic = msg.includes("No microphone");
|
||||
toast({
|
||||
title: isPermDenied
|
||||
? "Mic permission denied"
|
||||
: isNoMic
|
||||
? "No microphone found"
|
||||
: "Mic toggle failed",
|
||||
description: msg,
|
||||
tone: "vermilion",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleListen = () => {
|
||||
const next = !listenActive;
|
||||
listen.toggle(next);
|
||||
setListenActive(next);
|
||||
toast({
|
||||
title: next ? "Monitor activated" : "Monitor deactivated",
|
||||
tone: next ? "signal" : "neutral",
|
||||
});
|
||||
};
|
||||
|
||||
const containerRef = useStaggerReveal<HTMLDivElement>(".voice-tile", {
|
||||
stagger: 0.04,
|
||||
y: 8,
|
||||
dependencies: [status],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(ws);
|
||||
return unsub;
|
||||
}, [subscribe, ws]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.connected) ambient.set("signal", 0.45, "voice active");
|
||||
else ambient.set("vermilion", 0.25, "voice disconnected");
|
||||
}, [status?.connected, ambient]);
|
||||
|
||||
if (error && !status)
|
||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||
if (!status && isLoading)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SkeletonPanel rows={3} />
|
||||
<SkeletonPanel rows={5} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const connected = status?.connected ?? false;
|
||||
|
||||
const onConnect = async () => {
|
||||
if (!guildId || !channelId) {
|
||||
toast({ title: "Select target channel", tone: "vermilion" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await connect.mutateAsync({ guildId, channelId });
|
||||
toast({ title: "Connected to voice channel", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Connection failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDisconnect = async () => {
|
||||
try {
|
||||
await disconnect.mutateAsync();
|
||||
toast({ title: "Disconnected from voice", tone: "neutral" });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Disconnect failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<div ref={containerRef} className="space-y-4">
|
||||
{/* Precision Sub-Header Bar */}
|
||||
<div className="voice-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
connected
|
||||
? "bg-success shadow-[0_0_8px_var(--color-status-success)]"
|
||||
: "bg-ink-muted"
|
||||
}`}
|
||||
/>
|
||||
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||
Voice Spectrum & Stage Controls
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||
<span className="text-ink-muted">STAGE:</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 font-medium border ${
|
||||
connected
|
||||
? "border-success/30 bg-success/10 text-success"
|
||||
: "border-hairline bg-surface-2 text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{connected ? "LIVE_BRIDGE" : "OFFLINE"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Router Stage */}
|
||||
<div className="voice-tile">
|
||||
<GlassPanel className="p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<GuildChannelPicker
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
guildsInitial={initialGuilds}
|
||||
onChange={(g, c) => {
|
||||
setGuildId(g);
|
||||
setChannelId(c);
|
||||
}}
|
||||
mode="voice"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="md"
|
||||
onClick={onDisconnect}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<PhoneOff className="size-3.5" />
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={onConnect}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Radio className="size-3.5" />
|
||||
Connect Bridge
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Connected Bridges — multi-guild voice bridge roster */}
|
||||
<GlassPanel className="voice-tile">
|
||||
<SectionHeader
|
||||
eyebrow="Bridges"
|
||||
title="Connected Voice Bridges"
|
||||
action={
|
||||
<span className="mono text-xs text-[#8a8f98]">
|
||||
{status?.connections?.length ?? (connected ? 1 : 0)} ACTIVE
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
{status?.connections && status.connections.length > 0 ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{status.connections.map((conn) => {
|
||||
const isActive =
|
||||
conn.guildId === status.activeGuildId &&
|
||||
conn.channelId === status.activeChannelId;
|
||||
return (
|
||||
<div
|
||||
key={`${conn.guildId}-${conn.channelId}`}
|
||||
className={`flex items-center gap-2.5 rounded-[8px] border p-2.5 ${
|
||||
isActive
|
||||
? "border-success/40 bg-success/5"
|
||||
: "border-hairline bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex size-8 shrink-0 items-center justify-center rounded-full border ${
|
||||
isActive
|
||||
? "border-success/40 bg-success/10 text-success"
|
||||
: "border-hairline bg-surface text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
<Cable className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-ink">
|
||||
{conn.channelName || "#" + conn.channelId.slice(0, 8)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 font-mono text-[9px] text-ink-faint">
|
||||
<span className="truncate">
|
||||
G:{conn.guildId.slice(0, 8)} · C:
|
||||
{conn.channelId.slice(0, 8)}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="rounded bg-success/15 px-1 text-success">
|
||||
ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 font-mono text-[9px] text-ink-faint"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{new Date(conn.connectedAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : connected ? (
|
||||
<div className="flex items-center gap-2.5 rounded-[8px] border border-success/30 bg-success/5 p-2.5">
|
||||
<span className="flex size-8 items-center justify-center rounded-full border border-success/40 bg-success/10 text-success">
|
||||
<Cable className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-ink">
|
||||
{status?.activeChannelName || "Voice channel"}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-[9px] text-ink-faint">
|
||||
Active bridge · G:
|
||||
{(status?.activeGuildId ?? guildId ?? "").slice(0, 8)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded bg-success/15 px-1 font-mono text-[9px] text-success">
|
||||
ACTIVE
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[8px] border border-dashed border-hairline p-4 text-center">
|
||||
<span className="font-mono text-[10px] text-ink-faint">
|
||||
No active voice bridges
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Voice Stage Grid */}
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<GlassPanel className="voice-tile lg:col-span-2">
|
||||
<SectionHeader
|
||||
eyebrow="Participants"
|
||||
title="Active Stage Members"
|
||||
/>
|
||||
<div className="mt-4 min-h-[160px]">
|
||||
<VoiceStage speakers={speakers} />
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="voice-tile flex flex-col justify-between">
|
||||
<div>
|
||||
<SectionHeader eyebrow="Telemetry" title="Input / Output Mix" />
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Mic Toggle */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMic}
|
||||
className={`flex items-center gap-1.5 rounded-[8px] px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
micActive
|
||||
? "bg-signal/15 text-signal border border-signal/40 glow-pulse"
|
||||
: "bg-surface-2 text-ink-muted border border-hairline hover:border-signal/30 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<Mic className="size-3.5" />
|
||||
{micActive ? "MIC LIVE" : "MIC OFF"}
|
||||
</button>
|
||||
<span className="font-mono text-[11px] text-ink-muted">
|
||||
{micVol}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={150}
|
||||
value={micVol}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setMicVol(v);
|
||||
mic.setVolume(v / 100);
|
||||
}}
|
||||
className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-signal"
|
||||
/>
|
||||
{/* Live mic level meter */}
|
||||
{micActive && (
|
||||
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal transition-all duration-100"
|
||||
style={{
|
||||
width: `${Math.min(100, mic.micLevel * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Noise Suppression Toggle */}
|
||||
<div className="mt-2.5 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = !mic.noiseSuppression;
|
||||
mic.toggleNoiseSuppression(next);
|
||||
toast({
|
||||
title: next
|
||||
? "Noise suppression ON"
|
||||
: "Noise suppression OFF",
|
||||
tone: next ? "signal" : "neutral",
|
||||
});
|
||||
}}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-all ${
|
||||
mic.noiseSuppression
|
||||
? "bg-success/15 text-success border border-success/30"
|
||||
: "bg-surface-2 text-ink-faint border border-hairline hover:border-ink-muted/30"
|
||||
}`}
|
||||
>
|
||||
{mic.noiseSuppression ? (
|
||||
<ShieldCheck className="size-3" />
|
||||
) : (
|
||||
<ShieldOff className="size-3" />
|
||||
)}
|
||||
NS {mic.noiseSuppression ? "ON" : "OFF"}
|
||||
</button>
|
||||
{micActive && (
|
||||
<span className="font-mono text-[9px] text-ink-faint">
|
||||
{mic.noiseSuppression ? "noise gated" : "raw audio"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listen Toggle */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleListen}
|
||||
className={`flex items-center gap-1.5 rounded-[8px] px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
listenActive
|
||||
? "bg-success/15 text-success border border-success/40 glow-pulse"
|
||||
: "bg-surface-2 text-ink-muted border border-hairline hover:border-success/30 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<Volume2 className="size-3.5" />
|
||||
{listenActive ? "MONITOR LIVE" : "MONITOR OFF"}
|
||||
</button>
|
||||
<span className="font-mono text-[11px] text-ink-muted">
|
||||
{listenVol}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={150}
|
||||
value={listenVol}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setListenVol(v);
|
||||
listen.setVolume(v / 100);
|
||||
}}
|
||||
className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-success"
|
||||
/>
|
||||
{/* Per-speaker level meters */}
|
||||
{listenActive && listen.levels.size > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{Array.from(listen.levels.entries()).map(
|
||||
([hash, level]) => (
|
||||
<div key={hash} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[9px] text-ink-faint w-8">
|
||||
#{hash.toString(16).slice(-3)}
|
||||
</span>
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-success transition-all duration-100"
|
||||
style={{
|
||||
width: `${Math.min(100, level * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-hairline pt-3">
|
||||
<div className="font-mono text-[10px] text-ink-muted">
|
||||
CODEC: OPUS 48KHZ · LOW_LATENCY
|
||||
{mic.noiseSuppression ? " · NS_ACTIVE" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ListMusic, SkipForward, Square } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import {
|
||||
useMediaLoop,
|
||||
useMediaSkip,
|
||||
useMediaState,
|
||||
useMediaStop,
|
||||
useMediaWsSync,
|
||||
} from "@/hooks";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
/**
|
||||
* Persistent now-playing bar, fixed above the mobile dock / bottom of the
|
||||
* viewport. Hidden on the /media route (the full player lives there) and
|
||||
* entirely when nothing is queued. Shares the SWR media-state cache with
|
||||
* every other consumer, so state stays consistent across routes.
|
||||
*/
|
||||
export function MiniPlayer() {
|
||||
const ws = useWebSocket();
|
||||
const pathname = usePathname();
|
||||
const { data: media } = useMediaState();
|
||||
useMediaWsSync(ws);
|
||||
const skip = useMediaSkip();
|
||||
const stop = useMediaStop();
|
||||
const loop = useMediaLoop();
|
||||
const ambient = useAmbient();
|
||||
|
||||
const hidden = pathname === "/media";
|
||||
const current = hidden ? null : (media?.current ?? null);
|
||||
const playing = media?.playing ?? false;
|
||||
const queueLen = (media?.queue ?? []).length;
|
||||
|
||||
// Keep the ambient tint in sync while the bar is visible on non-media routes.
|
||||
useEffect(() => {
|
||||
if (hidden || !current) return;
|
||||
ambient.set(
|
||||
playing ? "signal" : "amber",
|
||||
playing ? 0.4 : 0.2,
|
||||
"mini-player",
|
||||
);
|
||||
}, [hidden, current, playing, ambient]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto fixed inset-x-3 bottom-[calc(4.5rem+env(safe-area-inset-bottom))] z-40",
|
||||
"md:inset-x-auto md:right-5 md:bottom-5 md:w-[22rem]",
|
||||
"animate-fade-up",
|
||||
)}
|
||||
>
|
||||
<div className="glass flex items-center gap-3 rounded-[14px] px-3 py-2.5 shadow-[0_12px_40px_-16px_oklch(0_0_0/0.7)]">
|
||||
<Link
|
||||
href="/media"
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
aria-label="Open full media player"
|
||||
>
|
||||
<span className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-hairline bg-surface-2">
|
||||
{current.thumbnailUrl ? (
|
||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||
<img
|
||||
src={current.thumbnailUrl}
|
||||
alt=""
|
||||
className={cn(
|
||||
"size-full object-cover",
|
||||
playing && "animate-spin-disc",
|
||||
)}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<ListMusic
|
||||
className={cn(
|
||||
"size-4",
|
||||
playing ? "text-signal" : "text-ink-faint",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{playing && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -inset-1 rounded-full border border-signal/30 animate-pulse-ring"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="eyebrow block !text-[0.55rem] leading-tight">
|
||||
{playing ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span aria-hidden className="flex h-2 items-end gap-[2px]">
|
||||
{[0, 1].map((i) => (
|
||||
<span
|
||||
key={`eq-${i}`}
|
||||
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||
style={{
|
||||
animationDelay: `${i * 180}ms`,
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
now playing
|
||||
</span>
|
||||
) : (
|
||||
"paused"
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-sm text-ink">
|
||||
{current.title}
|
||||
</span>
|
||||
{current.durationMs != null && (
|
||||
<span className="mono block text-[0.6rem] text-ink-faint">
|
||||
{formatDuration(current.durationMs)}
|
||||
{queueLen > 0 && ` · ${queueLen} in queue`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => skip.mutate()}
|
||||
disabled={skip.isPending}
|
||||
aria-label="Skip to next track"
|
||||
className="flex size-8 items-center justify-center rounded-full text-ink-soft transition-colors hover:bg-surface hover:text-signal active:scale-95"
|
||||
>
|
||||
<SkipForward className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending}
|
||||
aria-label="Stop playback"
|
||||
className="flex size-8 items-center justify-center rounded-full text-ink-faint transition-colors hover:bg-vermilion/15 hover:text-vermilion active:scale-95"
|
||||
>
|
||||
<Square className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loop.mutate(!media?.loop)}
|
||||
aria-pressed={!!media?.loop}
|
||||
aria-label="Toggle loop"
|
||||
className={`hidden size-8 items-center justify-center rounded-full text-xs transition-colors sm:flex ${
|
||||
media?.loop
|
||||
? "bg-signal/15 text-signal"
|
||||
: "text-ink-faint hover:bg-surface hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,27 +2,23 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Select, type SelectOption } from "@/components/primitives";
|
||||
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
|
||||
import { useGuilds, useTextChannels } from "@/hooks";
|
||||
import type { Guild } from "@/lib/types";
|
||||
|
||||
export function GuildChannelPicker({
|
||||
mode,
|
||||
guildsInitial,
|
||||
guildId,
|
||||
channelId,
|
||||
onChange,
|
||||
}: {
|
||||
mode: "voice" | "text";
|
||||
guildsInitial?: Guild[];
|
||||
guildId: string | null;
|
||||
channelId: string | null;
|
||||
onChange: (guildId: string, channelId: string | null) => void;
|
||||
}) {
|
||||
const { data: guilds } = useGuilds(guildsInitial);
|
||||
// Call both hooks unconditionally (rules of hooks); select by mode.
|
||||
const voiceChannels = useVoiceChannels(guildId ?? "");
|
||||
const textChannels = useTextChannels(guildId ?? "");
|
||||
const channels = mode === "voice" ? voiceChannels.data : textChannels.data;
|
||||
const channels = textChannels.data;
|
||||
|
||||
const [g, setG] = useState(guildId);
|
||||
const [c, setC] = useState(channelId);
|
||||
@@ -61,7 +57,7 @@ export function GuildChannelPicker({
|
||||
if (g) onChange(g, v);
|
||||
}}
|
||||
options={channelOpts}
|
||||
placeholder={mode === "voice" ? "Voice channel" : "Text channel"}
|
||||
placeholder="Text channel"
|
||||
size="sm"
|
||||
className="w-full sm:w-52"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MiniPlayer } from "@/components/media/mini-player";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
import { NavRail } from "./nav-rail";
|
||||
import { TopBar } from "./topbar";
|
||||
@@ -10,8 +9,7 @@ import { TopBar } from "./topbar";
|
||||
*
|
||||
* < md the side rail collapses (hidden) and a bottom tab bar (MobileNav)
|
||||
* takes over navigation; the content region gains bottom padding so the last
|
||||
* panel never hides behind the dock. A persistent MiniPlayer floats at the
|
||||
* bottom-right whenever a media track is loaded outside /media.
|
||||
* panel never hides behind the dock.
|
||||
*/
|
||||
export function AppFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -24,7 +22,6 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
|
||||
</main>
|
||||
</div>
|
||||
<MobileNav />
|
||||
<MiniPlayer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Pause, Play, Signal, Volume2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Single-playback registry: playing one clip pauses every other instance.
|
||||
* Module-level so it survives across cards without a context provider.
|
||||
*/
|
||||
const activePlayers = new Set<() => void>();
|
||||
function registerPlayer(pause: () => void): () => void {
|
||||
activePlayers.add(pause);
|
||||
return () => activePlayers.delete(pause);
|
||||
}
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return "0:00";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Shared event wiring for a video element (kept small to avoid dup logic). */
|
||||
function wireVideoEvents(
|
||||
el: HTMLVideoElement,
|
||||
onLoadedMeta: () => void,
|
||||
onTime: () => void,
|
||||
onEnd: () => void,
|
||||
onPause: () => void,
|
||||
onPlaying: () => void,
|
||||
): void {
|
||||
el.addEventListener("loadedmetadata", onLoadedMeta);
|
||||
el.addEventListener("durationchange", onLoadedMeta);
|
||||
el.addEventListener("timeupdate", onTime);
|
||||
el.addEventListener("ended", onEnd);
|
||||
el.addEventListener("pause", onPause);
|
||||
el.addEventListener("playing", onPlaying);
|
||||
}
|
||||
|
||||
/** Remove the listeners added by {@link wireVideoEvents}. */
|
||||
function unwireVideoEvents(
|
||||
el: HTMLVideoElement,
|
||||
onLoadedMeta: () => void,
|
||||
onTime: () => void,
|
||||
onEnd: () => void,
|
||||
onPause: () => void,
|
||||
onPlaying: () => void,
|
||||
): void {
|
||||
el.removeEventListener("loadedmetadata", onLoadedMeta);
|
||||
el.removeEventListener("durationchange", onLoadedMeta);
|
||||
el.removeEventListener("timeupdate", onTime);
|
||||
el.removeEventListener("ended", onEnd);
|
||||
el.removeEventListener("pause", onPause);
|
||||
el.removeEventListener("playing", onPlaying);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
label?: string;
|
||||
/** True when the recording is video (camera/screen-share MP4). */
|
||||
video?: boolean;
|
||||
/** Lifted state: parent highlights the card that owns the active player. */
|
||||
onPlayStateChange?: (playing: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom recording player replacing native `<audio controls>`:
|
||||
* play/pause with buffering spinner, click-to-seek progress bar, time label,
|
||||
* animated equalizer bars while playing, and single-playback enforcement
|
||||
* (starting one clip pauses all others).
|
||||
*
|
||||
* For `video` recordings it renders a native `<video controls>` instead —
|
||||
* video needs the browser's own scrubbing/fullscreen UI — but still honors
|
||||
* the single-playback registry and lifts play state to the parent card.
|
||||
*/
|
||||
export function RecordingAudioPlayer({
|
||||
src,
|
||||
label = "Voice recording",
|
||||
video = false,
|
||||
onPlayStateChange,
|
||||
className,
|
||||
}: Props) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
// Audio: hide an HTMLAudioElement and drive it with the custom scrubber.
|
||||
useEffect(() => {
|
||||
if (video) return;
|
||||
const audio = new Audio();
|
||||
audio.preload = "metadata";
|
||||
audio.src = src;
|
||||
audioRef.current = audio;
|
||||
|
||||
const onLoadedMeta = () => setDuration(audio.duration || 0);
|
||||
const onTime = () => setCurrent(audio.currentTime);
|
||||
const onEnd = () => {
|
||||
setPlaying(false);
|
||||
setBuffering(false);
|
||||
setCurrent(0);
|
||||
audio.currentTime = 0;
|
||||
};
|
||||
const onPause = () => {
|
||||
setPlaying(false);
|
||||
setBuffering(false);
|
||||
};
|
||||
const onPlaying = () => {
|
||||
setPlaying(true);
|
||||
setBuffering(false);
|
||||
};
|
||||
const onWaiting = () => setBuffering(true);
|
||||
|
||||
audio.addEventListener("loadedmetadata", onLoadedMeta);
|
||||
audio.addEventListener("durationchange", onLoadedMeta);
|
||||
audio.addEventListener("timeupdate", onTime);
|
||||
audio.addEventListener("ended", onEnd);
|
||||
audio.addEventListener("pause", onPause);
|
||||
audio.addEventListener("playing", onPlaying);
|
||||
audio.addEventListener("play", onWaiting);
|
||||
audio.addEventListener("waiting", onWaiting);
|
||||
|
||||
// Single playback: while this player is active, pause any other that starts.
|
||||
const pauseThis = () => audio.pause();
|
||||
let unregister: (() => void) | null = null;
|
||||
const onPlayEvt = () => {
|
||||
for (const other of activePlayers) {
|
||||
if (other !== pauseThis) other();
|
||||
}
|
||||
unregister?.();
|
||||
unregister = registerPlayer(pauseThis);
|
||||
};
|
||||
audio.addEventListener("play", onPlayEvt);
|
||||
|
||||
return () => {
|
||||
unregister?.();
|
||||
audio.pause();
|
||||
audio.removeEventListener("loadedmetadata", onLoadedMeta);
|
||||
audio.removeEventListener("durationchange", onLoadedMeta);
|
||||
audio.removeEventListener("timeupdate", onTime);
|
||||
audio.removeEventListener("ended", onEnd);
|
||||
audio.removeEventListener("pause", onPause);
|
||||
audio.removeEventListener("playing", onPlaying);
|
||||
audio.removeEventListener("play", onWaiting);
|
||||
audio.removeEventListener("waiting", onWaiting);
|
||||
audio.removeEventListener("play", onPlayEvt);
|
||||
audio.src = "";
|
||||
audioRef.current = null;
|
||||
};
|
||||
}, [src, video]);
|
||||
|
||||
// Video: wire the rendered <video controls> for single-playback + lifted state.
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onLoadedMeta = () => setDuration(el.duration || 0);
|
||||
const onTime = () => setCurrent(el.currentTime);
|
||||
const onEnd = () => {
|
||||
setPlaying(false);
|
||||
setCurrent(0);
|
||||
};
|
||||
const onPause = () => setPlaying(false);
|
||||
const onPlaying = () => setPlaying(true);
|
||||
wireVideoEvents(el, onLoadedMeta, onTime, onEnd, onPause, onPlaying);
|
||||
|
||||
// Single playback: pausing any other video/audio that starts.
|
||||
const pauseThis = () => el.pause();
|
||||
let unregister: (() => void) | null = null;
|
||||
const onPlayEvt = () => {
|
||||
for (const other of activePlayers) {
|
||||
if (other !== pauseThis) other();
|
||||
}
|
||||
unregister?.();
|
||||
unregister = registerPlayer(pauseThis);
|
||||
};
|
||||
el.addEventListener("play", onPlayEvt);
|
||||
|
||||
return () => {
|
||||
unregister?.();
|
||||
unwireVideoEvents(el, onLoadedMeta, onTime, onEnd, onPause, onPlaying);
|
||||
el.removeEventListener("play", onPlayEvt);
|
||||
};
|
||||
}, [video]);
|
||||
|
||||
useEffect(() => {
|
||||
onPlayStateChange?.(playing || buffering);
|
||||
}, [playing, buffering, onPlayStateChange]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const el = video ? videoRef.current : audioRef.current;
|
||||
if (!el) return;
|
||||
if (el.paused) {
|
||||
setBuffering(true);
|
||||
void el.play().catch(() => setBuffering(false));
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
}, [video]);
|
||||
|
||||
const seek = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const el = video ? videoRef.current : audioRef.current;
|
||||
if (!el || !Number.isFinite(el.duration)) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.min(
|
||||
1,
|
||||
Math.max(0, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
el.currentTime = ratio * el.duration;
|
||||
setCurrent(el.currentTime);
|
||||
},
|
||||
[video],
|
||||
);
|
||||
|
||||
const pct = duration > 0 ? (current / duration) * 100 : 0;
|
||||
|
||||
if (video) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[8px] border bg-surface-2 p-1.5 transition-colors",
|
||||
playing
|
||||
? "border-signal/40 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
|
||||
: "border-hairline",
|
||||
className,
|
||||
)}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="aspect-video w-full rounded-[5px] bg-black object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[8px] border bg-surface-2 px-3 py-2.5 transition-colors",
|
||||
playing || buffering
|
||||
? "border-signal/40 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
|
||||
: "border-hairline",
|
||||
className,
|
||||
)}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={playing}
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-full border transition-all active:scale-95",
|
||||
playing || buffering
|
||||
? "border-signal/50 bg-signal/15 text-signal"
|
||||
: "border-hairline bg-surface text-ink-soft hover:border-signal/40 hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{buffering ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : playing ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 translate-x-[1px]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* seekable progress */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.round(duration)}
|
||||
aria-valuenow={Math.round(current)}
|
||||
tabIndex={0}
|
||||
onClick={seek}
|
||||
onKeyDown={(e) => {
|
||||
const el = video ? videoRef.current : audioRef.current;
|
||||
if (!el || !Number.isFinite(el.duration)) return;
|
||||
if (e.key === "ArrowRight")
|
||||
el.currentTime = Math.min(el.duration, el.currentTime + 5);
|
||||
if (e.key === "ArrowLeft")
|
||||
el.currentTime = Math.max(0, el.currentTime - 5);
|
||||
}}
|
||||
className="group relative h-4 cursor-pointer"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-surface">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width]",
|
||||
(playing || buffering) && "bg-signal/80",
|
||||
!playing && !buffering && "bg-signal/40",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{(playing || buffering) && (
|
||||
<span
|
||||
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-signal shadow-[0_0_8px_var(--color-signal-glow)] transition-[left]"
|
||||
style={{ left: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mono mt-1 flex items-center justify-between text-[0.6rem] text-ink-faint">
|
||||
<span>{formatTime(current)}</span>
|
||||
{/* equalizer bars while playing */}
|
||||
{(playing || buffering) && (
|
||||
<span className="flex h-3 items-end gap-[2px]" aria-hidden>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<span
|
||||
key={`eq-${i}`}
|
||||
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||
style={{ animationDelay: `${i * 140}ms`, height: "100%" }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Volume2 className="size-3" />
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small "now playing" chip used by the card header. */
|
||||
export function NowPlayingChip() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 text-[0.6rem] font-medium text-signal">
|
||||
<Signal className="size-3 animate-pulse" />
|
||||
now playing
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Radio } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Avatar } from "@/components/primitives";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
|
||||
export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Only show actively speaking users on the stage orbit
|
||||
const activeSpeakers = speakers.filter((s) => s.speaking);
|
||||
const n = activeSpeakers.length;
|
||||
const totalConnected = speakers.length;
|
||||
const live = n > 0;
|
||||
|
||||
// CSS stagger reveal for speaker nodes
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const nodes = container.querySelectorAll<HTMLElement>(".speaker-node");
|
||||
if (nodes.length === 0) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
nodes.forEach((el, i) => {
|
||||
el.style.opacity = "0";
|
||||
el.style.animationFillMode = "forwards";
|
||||
el.style.animationTimingFunction = "cubic-bezier(0.34, 1.56, 0.64, 1)";
|
||||
el.style.animationName = "scale-bounce-in";
|
||||
el.style.animationDuration = "0.4s";
|
||||
el.style.animationDelay = `${i * 0.05}s`;
|
||||
});
|
||||
|
||||
return () => {
|
||||
nodes.forEach((el) => {
|
||||
el.style.removeProperty("opacity");
|
||||
el.style.removeProperty("animation-name");
|
||||
el.style.removeProperty("animation-duration");
|
||||
el.style.removeProperty("animation-delay");
|
||||
el.style.removeProperty("animation-fill-mode");
|
||||
el.style.removeProperty("animation-timing-function");
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// CSS pulse ring for active speakers
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
const rings = container.querySelectorAll<HTMLElement>(
|
||||
".speaker-pulse-ring",
|
||||
);
|
||||
if (rings.length === 0) return;
|
||||
|
||||
rings.forEach((el, i) => {
|
||||
el.style.animationName = "pulse-ring";
|
||||
el.style.animationDuration = "1.1s";
|
||||
el.style.animationTimingFunction = "sine.in-out";
|
||||
el.style.animationIterationCount = "infinite";
|
||||
el.style.animationDelay = `${i * 0.08}s`;
|
||||
});
|
||||
|
||||
return () => {
|
||||
rings.forEach((el) => {
|
||||
el.style.removeProperty("animation-name");
|
||||
el.style.removeProperty("animation-duration");
|
||||
el.style.removeProperty("animation-timing-function");
|
||||
el.style.removeProperty("animation-iteration-count");
|
||||
el.style.removeProperty("animation-delay");
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mx-auto aspect-square w-full max-w-[380px]"
|
||||
>
|
||||
<div className="absolute inset-4 rounded-full border border-hairline/30" />
|
||||
<div className="absolute inset-12 rounded-full border border-dashed border-hairline/40 animate-spin-disc radar-sweep" />
|
||||
<div className="absolute left-1/2 top-1/2 size-80 -translate-x-1/2 -translate-y-1/2 rounded-full border border-hairline/20" />
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 size-56 -translate-x-1/2 -translate-y-1/2 rounded-full bg-signal/5 transition-opacity duration-500"
|
||||
style={{ opacity: live ? 1 : 0.2 }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 flex size-28 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-full border backdrop-blur-md transition-all duration-300"
|
||||
style={{
|
||||
borderColor: live ? "var(--color-signal)" : "var(--color-hairline)",
|
||||
boxShadow: live ? "0 0 50px -8px var(--color-signal-glow)" : "none",
|
||||
background: "oklch(0.15 0.02 70 / 0.7)",
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
className={`size-7 transition-colors ${live ? "text-signal animate-breathe" : "text-ink-faint"}`}
|
||||
/>
|
||||
<span className="font-mono mt-1 text-[11px] font-bold tracking-wider text-ink uppercase">
|
||||
{live ? `${n} SPEAKING` : `${totalConnected} CONNECTED`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeSpeakers.map((s, i) => {
|
||||
const angle = (i / Math.max(n, 1)) * Math.PI * 2 - Math.PI / 2;
|
||||
const radius = 44;
|
||||
const x = 50 + radius * Math.cos(angle);
|
||||
const y = 50 + radius * Math.sin(angle);
|
||||
return (
|
||||
<div
|
||||
key={s.userId}
|
||||
className="speaker-node absolute -translate-x-1/2 -translate-y-1/2 transition-all duration-300"
|
||||
style={{ left: `${x}%`, top: `${y}%` }}
|
||||
>
|
||||
<div className="relative flex flex-col items-center gap-1.5">
|
||||
<span className="relative">
|
||||
<Avatar
|
||||
src={s.avatar}
|
||||
name={s.username}
|
||||
size={50}
|
||||
ring={s.speaking}
|
||||
/>
|
||||
{s.speaking && (
|
||||
<span className="speaker-pulse-ring absolute -inset-1 rounded-full ring-2 ring-signal ring-offset-2 ring-offset-canvas" />
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono max-w-[100px] truncate rounded-md border border-hairline bg-canvas-2/90 px-2 py-0.5 text-[10px] font-semibold text-ink shadow-md backdrop-blur-md">
|
||||
{s.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,14 +12,6 @@ export {
|
||||
} from "./use-dashboard";
|
||||
export { useGuilds } from "./use-guilds";
|
||||
export { useChannelCultures, useGlossary } from "./use-knowledge";
|
||||
export {
|
||||
useMediaLoop,
|
||||
useMediaQueue,
|
||||
useMediaSkip,
|
||||
useMediaState,
|
||||
useMediaStop,
|
||||
useMediaWsSync,
|
||||
} from "./use-media";
|
||||
export {
|
||||
useImages,
|
||||
useLoadMore,
|
||||
@@ -47,21 +39,3 @@ export {
|
||||
useTopFlaggedChannels,
|
||||
useTopFlaggedDomains,
|
||||
} from "./use-moderation";
|
||||
export {
|
||||
type RecordingsFilter,
|
||||
useDeleteRecording,
|
||||
useLoadMoreRecordings,
|
||||
useRecordings,
|
||||
useRecordingsSummary,
|
||||
useRecordingsWsSync,
|
||||
} from "./use-recordings";
|
||||
export {
|
||||
hashUserId,
|
||||
useMicTransmit,
|
||||
useSpeakers,
|
||||
useVoiceChannels,
|
||||
useVoiceConnect,
|
||||
useVoiceDisconnect,
|
||||
useVoiceListen,
|
||||
useVoiceStatus,
|
||||
} from "./use-voice";
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import useSWR from "swr";
|
||||
|
||||
import { voiceApi } from "@/lib/api";
|
||||
import { messagesApi } from "@/lib/api";
|
||||
import type { Guild } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Fetch the list of available Discord guilds.
|
||||
* Fetch the list of available Discord guilds (derived from the message
|
||||
* archive by the backend).
|
||||
*/
|
||||
export function useGuilds(initialData?: Guild[]) {
|
||||
return useSWR<Guild[]>(["guilds"], () => voiceApi.getGuilds(), {
|
||||
return useSWR<Guild[]>(["guilds"], () => messagesApi.getGuilds(), {
|
||||
dedupingInterval: 60_000,
|
||||
fallbackData: initialData,
|
||||
});
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useAction } from "@/hooks/use-action";
|
||||
import { mediaApi } from "@/lib/api";
|
||||
import type { MediaState } from "@/lib/types";
|
||||
import type { WsHook } from "@/lib/ws-hook";
|
||||
|
||||
const MEDIA_KEY = ["media-state"] as const;
|
||||
|
||||
export function useMediaState(initialData?: MediaState) {
|
||||
return useSWR<MediaState>(MEDIA_KEY, () => mediaApi.getStatus(), {
|
||||
refreshInterval: 10_000,
|
||||
shouldRetryOnError: false,
|
||||
fallbackData: initialData,
|
||||
});
|
||||
}
|
||||
|
||||
function useMediaAction<TArgs>(fn: (args: TArgs) => Promise<MediaState>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
return useAction(fn, {
|
||||
onSuccess: (data) => {
|
||||
void mutate(MEDIA_KEY, data, { revalidate: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMediaQueue() {
|
||||
return useMediaAction((input: { url: string; mode?: "music" | "screen" }) =>
|
||||
mediaApi.queue(input.url, input.mode ?? "music"),
|
||||
);
|
||||
}
|
||||
|
||||
export function useMediaSkip() {
|
||||
return useMediaAction(() => mediaApi.skip());
|
||||
}
|
||||
|
||||
export function useMediaStop() {
|
||||
return useMediaAction(() => mediaApi.stop());
|
||||
}
|
||||
|
||||
export function useMediaLoop() {
|
||||
return useMediaAction((loop: boolean) => mediaApi.loop(loop));
|
||||
}
|
||||
|
||||
/** Subscribe to WS media_state events to keep cache fresh */
|
||||
export function useMediaWsSync(ws: WsHook) {
|
||||
const { mutate } = useSWRConfig();
|
||||
useEffect(() => {
|
||||
const unsub = ws.on("media_state", (data) => {
|
||||
void mutate(MEDIA_KEY, data as MediaState, { revalidate: false });
|
||||
});
|
||||
return unsub;
|
||||
}, [ws, mutate]);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useAction } from "@/hooks/use-action";
|
||||
import { messagesApi, voiceApi } from "@/lib/api";
|
||||
import { messagesApi } from "@/lib/api";
|
||||
import type {
|
||||
AttachmentRecord,
|
||||
Channel,
|
||||
@@ -108,7 +108,7 @@ export function useLoadMore() {
|
||||
|
||||
export function useTextChannels(guildId: string) {
|
||||
return useSWR<Channel[]>(guildId ? ["text-channels", guildId] : null, () =>
|
||||
voiceApi.getTextChannels(guildId),
|
||||
messagesApi.getTextChannels(guildId),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useAction } from "@/hooks/use-action";
|
||||
import { type RecordingListParams, recordingsApi } from "@/lib/api/recordings";
|
||||
import type {
|
||||
PaginatedRecordings,
|
||||
SpeakerSummary,
|
||||
VoiceRecording,
|
||||
} from "@/lib/types";
|
||||
import type { WsHook } from "@/lib/ws-hook";
|
||||
|
||||
const RECORDINGS_PREFIX = "recordings";
|
||||
const RECORDINGS_SUMMARY_KEY = ["recordings", "summary"] as const;
|
||||
|
||||
export interface RecordingsFilter {
|
||||
channelId?: string;
|
||||
userId?: string;
|
||||
/** keyword search */
|
||||
q?: string;
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
function recordingsKey(filter: RecordingsFilter = {}): unknown[] {
|
||||
return [
|
||||
RECORDINGS_PREFIX,
|
||||
"list",
|
||||
filter.channelId ?? "",
|
||||
filter.userId ?? "",
|
||||
filter.q ?? "",
|
||||
filter.startDate ?? "",
|
||||
filter.endDate ?? "",
|
||||
];
|
||||
}
|
||||
|
||||
/** Match every recordings-list cache key so mutation/sync hits all filters. */
|
||||
function isRecordingsListKey(key: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key.length === 7 &&
|
||||
key[0] === RECORDINGS_PREFIX &&
|
||||
key[1] === "list"
|
||||
);
|
||||
}
|
||||
|
||||
function baseParams(filter: RecordingsFilter = {}): RecordingListParams {
|
||||
return {
|
||||
channelId: filter.channelId,
|
||||
userId: filter.userId,
|
||||
q: filter.q,
|
||||
startDate: filter.startDate,
|
||||
endDate: filter.endDate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRecordingsPage(
|
||||
initialPage?: PaginatedRecordings,
|
||||
filter: RecordingsFilter = {},
|
||||
) {
|
||||
return useSWR<PaginatedRecordings>(
|
||||
recordingsKey(filter),
|
||||
() => recordingsApi.list({ ...baseParams(filter), limit: 50 }),
|
||||
// fallbackData only applies to the unfiltered "all" view; a filtered cache
|
||||
// must not be pre-seeded with unfiltered rows (would flash wrong data).
|
||||
{
|
||||
fallbackData:
|
||||
filter.channelId ||
|
||||
filter.userId ||
|
||||
filter.q ||
|
||||
filter.startDate ||
|
||||
filter.endDate
|
||||
? undefined
|
||||
: initialPage,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useRecordings(
|
||||
initialPage?: PaginatedRecordings,
|
||||
filter: RecordingsFilter = {},
|
||||
) {
|
||||
const page = useRecordingsPage(initialPage, filter);
|
||||
return {
|
||||
...page,
|
||||
data: page.data?.items,
|
||||
nextCursor: page.data?.nextCursor ?? null,
|
||||
hasMore: page.data?.hasMore ?? false,
|
||||
refetch: () => page.mutate(),
|
||||
};
|
||||
}
|
||||
|
||||
export function useLoadMoreRecordings(filter: RecordingsFilter = {}) {
|
||||
const { mutate } = useSWRConfig();
|
||||
return useAction(async ({ cursor }: { cursor: string }) => {
|
||||
const result = await recordingsApi.list({
|
||||
...baseParams(filter),
|
||||
limit: 50,
|
||||
cursor,
|
||||
});
|
||||
await mutate(
|
||||
recordingsKey(filter),
|
||||
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
||||
if (!old) return result;
|
||||
const existingIds = new Set(old.items.map((r) => r.id));
|
||||
const newUnique = result.items.filter((r) => !existingIds.has(r.id));
|
||||
return {
|
||||
items: [...old.items, ...newUnique],
|
||||
nextCursor: result.nextCursor,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRecording() {
|
||||
const { mutate } = useSWRConfig();
|
||||
return useAction((id: string) => recordingsApi.delete(id), {
|
||||
onSuccess: (_, id) => {
|
||||
// A deleted recording disappears from every filter view.
|
||||
void mutate(
|
||||
(key: unknown) => isRecordingsListKey(key),
|
||||
(
|
||||
old: PaginatedRecordings | undefined,
|
||||
): PaginatedRecordings | undefined => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
items: old.items.filter((r) => r.id !== id),
|
||||
};
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
// Leaderboard totals shifted — refresh.
|
||||
void mutate(RECORDINGS_SUMMARY_KEY);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecordingsWsSync(ws: WsHook) {
|
||||
const { mutate } = useSWRConfig();
|
||||
useEffect(() => {
|
||||
const unsub = ws.on("voice_recording_uploaded", (data) => {
|
||||
const rec = data as VoiceRecording;
|
||||
// A fresh recording should appear in every filter view it belongs to.
|
||||
void mutate(
|
||||
(key: unknown) => isRecordingsListKey(key),
|
||||
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
||||
if (!old) return { items: [rec], nextCursor: null, hasMore: false };
|
||||
if (old.items.some((r) => r.id === rec.id)) return old;
|
||||
return {
|
||||
...old,
|
||||
items: [rec, ...old.items],
|
||||
};
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
void mutate(RECORDINGS_SUMMARY_KEY);
|
||||
});
|
||||
return unsub;
|
||||
}, [ws, mutate]);
|
||||
}
|
||||
|
||||
export function useRecordingsSummary() {
|
||||
return useSWR<SpeakerSummary[]>(RECORDINGS_SUMMARY_KEY, () =>
|
||||
recordingsApi.summary(),
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useAction } from "@/hooks/use-action";
|
||||
import { voiceApi } from "@/lib/api";
|
||||
import { MicTransmitter } from "@/lib/audio/mic-transmit";
|
||||
import { PcmPlayer } from "@/lib/audio/pcm-player";
|
||||
import { hashUserId } from "@/lib/hash";
|
||||
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
|
||||
import type { PcmChunk } from "@/lib/ws/types";
|
||||
import type { WsHook } from "@/lib/ws-hook";
|
||||
|
||||
// Re-export for components that still import hashUserId from this module.
|
||||
export { hashUserId };
|
||||
|
||||
const STATUS_KEY = ["voice-status"] as const;
|
||||
|
||||
export function useVoiceStatus(initialData?: VoiceStatus) {
|
||||
return useSWR<VoiceStatus>(STATUS_KEY, () => voiceApi.getStatus(), {
|
||||
shouldRetryOnError: false,
|
||||
fallbackData: initialData,
|
||||
refreshInterval: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useVoiceChannels(guildId: string) {
|
||||
return useSWR<Channel[]>(guildId ? ["voice-channels", guildId] : null, () =>
|
||||
voiceApi.getVoiceChannels(guildId),
|
||||
);
|
||||
}
|
||||
|
||||
const SPEAKERS_KEY = ["voice-speakers"] as const;
|
||||
|
||||
/**
|
||||
* Live shared speaker state.
|
||||
*
|
||||
* Seeded from the server-authored snapshot (`initial` — the voice status the
|
||||
* server rendered, which includes the authoritative active speakers). From
|
||||
* there the WS keeps it converged across ALL users:
|
||||
* - `voice_state` → authoritative FULL replacement (e.g. a late join seeds
|
||||
* every client with the same list);
|
||||
* - `voice_active_user` → incremental upsert of a single speaker delta.
|
||||
*
|
||||
* Now backed by SWR (consistent with all other hooks) so cache, revalidation,
|
||||
* and deduping apply. WS events mutate the SWR cache directly
|
||||
* ({ revalidate: false }) to avoid refetching the full status.
|
||||
*
|
||||
* This replaces the old per-browser model where each tab accumulated speakers
|
||||
* only from events it happened to receive while mounted.
|
||||
*/
|
||||
const SPEAKER_TTL_MS = 30_000;
|
||||
|
||||
/** Remove speakers that haven't been active recently. */
|
||||
function filterStale(speakers: ActiveSpeaker[]): ActiveSpeaker[] {
|
||||
const now = Date.now();
|
||||
return speakers.filter((s) => {
|
||||
if (s.speaking) return true;
|
||||
if (s.lastActiveAt && now - s.lastActiveAt > SPEAKER_TTL_MS) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
|
||||
const {
|
||||
data: speakers,
|
||||
error,
|
||||
mutate,
|
||||
isValidating,
|
||||
} = useSWR<ActiveSpeaker[]>(SPEAKERS_KEY, () => Promise.resolve([]), {
|
||||
fallbackData: initialStatusActive ?? [],
|
||||
revalidateOnMount: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
|
||||
const subscribe = useCallback(
|
||||
(ws: WsHook) => {
|
||||
const unsubSnapshot = ws.on("voice_state", (data) => {
|
||||
const state = data as { activeSpeakers?: ActiveSpeaker[] };
|
||||
if (Array.isArray(state?.activeSpeakers)) {
|
||||
void mutate(filterStale(state.activeSpeakers), { revalidate: false });
|
||||
}
|
||||
});
|
||||
const unsub = ws.on("voice_active_user", (data) => {
|
||||
const speaker = data as ActiveSpeaker;
|
||||
void mutate(
|
||||
(prev: ActiveSpeaker[] | undefined) => {
|
||||
const arr = prev ?? [];
|
||||
const idx = arr.findIndex((s) => s.userId === speaker.userId);
|
||||
const next = [...arr];
|
||||
if (idx >= 0) {
|
||||
next[idx] = speaker;
|
||||
} else {
|
||||
next.push(speaker);
|
||||
}
|
||||
return filterStale(next);
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubSnapshot();
|
||||
unsub();
|
||||
};
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
// Periodic stale speaker cleanup (every 10s)
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
void mutate((prev) => (prev ? filterStale(prev) : prev), {
|
||||
revalidate: false,
|
||||
});
|
||||
}, 10_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [mutate]);
|
||||
|
||||
return { speakers: speakers ?? [], subscribe, error, isValidating };
|
||||
}
|
||||
|
||||
function useStatusInvalidator() {
|
||||
const { mutate } = useSWRConfig();
|
||||
return useCallback(() => {
|
||||
void mutate(STATUS_KEY);
|
||||
}, [mutate]);
|
||||
}
|
||||
|
||||
export function useVoiceConnect() {
|
||||
const invalidate = useStatusInvalidator();
|
||||
return useAction(
|
||||
({ guildId, channelId }: { guildId: string; channelId: string }) =>
|
||||
voiceApi.connect(guildId, channelId),
|
||||
{ onSuccess: invalidate },
|
||||
);
|
||||
}
|
||||
|
||||
export function useVoiceDisconnect() {
|
||||
const invalidate = useStatusInvalidator();
|
||||
return useAction(() => voiceApi.disconnect(), { onSuccess: invalidate });
|
||||
}
|
||||
|
||||
export function useMicTransmit(ws: {
|
||||
sendBinary: (data: ArrayBufferLike) => void;
|
||||
}) {
|
||||
const transmitterRef = useRef<MicTransmitter | null>(null);
|
||||
const [micLevel, setMicLevel] = useState(0);
|
||||
const [noiseSuppression, setNoiseSuppressionState] = useState(true);
|
||||
|
||||
const action = useAction(async (active: boolean) => {
|
||||
if (active) {
|
||||
const transmitter = new MicTransmitter((frame) => ws.sendBinary(frame), {
|
||||
noiseSuppression,
|
||||
});
|
||||
transmitterRef.current = transmitter;
|
||||
await transmitter.start();
|
||||
await voiceApi.sendCommand("voice:transmit:start");
|
||||
} else {
|
||||
transmitterRef.current?.stop();
|
||||
transmitterRef.current = null;
|
||||
setMicLevel(0);
|
||||
await voiceApi.sendCommand("voice:transmit:stop");
|
||||
}
|
||||
});
|
||||
|
||||
const setVolume = useCallback((volume: number) => {
|
||||
transmitterRef.current?.setVolume(volume / 100);
|
||||
}, []);
|
||||
|
||||
const toggleNoiseSuppression = useCallback((enabled: boolean) => {
|
||||
setNoiseSuppressionState(enabled);
|
||||
// If mic is already active, toggling NS requires restart
|
||||
if (transmitterRef.current?.isActive) {
|
||||
transmitterRef.current.setNoiseSuppression(enabled);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll the analyser RMS so the UI can render a live input meter.
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setMicLevel(transmitterRef.current?.getLevel() ?? 0);
|
||||
}, 120);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
transmitterRef.current?.stop();
|
||||
transmitterRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...action,
|
||||
setVolume,
|
||||
micLevel,
|
||||
noiseSuppression,
|
||||
toggleNoiseSuppression,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive + play Discord voice in the browser.
|
||||
*
|
||||
* Toggling on (from a user gesture) starts a PcmPlayer, subscribes to the WS
|
||||
* binary PCM stream, and exposes per-user activity levels for the waveform UI.
|
||||
* `toggle(false)` tears everything down.
|
||||
*/
|
||||
export function useVoiceListen(ws: {
|
||||
onPcm: (handler: (chunk: PcmChunk) => void) => () => void;
|
||||
}) {
|
||||
const playerRef = useRef<PcmPlayer | null>(null);
|
||||
const unsubRef = useRef<(() => void) | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [levels, setLevels] = useState<Map<number, number>>(new Map());
|
||||
|
||||
const stop = useCallback(() => {
|
||||
unsubRef.current?.();
|
||||
unsubRef.current = null;
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
playerRef.current?.stop();
|
||||
playerRef.current = null;
|
||||
setActive(false);
|
||||
setLevels(new Map());
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(
|
||||
(on: boolean) => {
|
||||
if (on) {
|
||||
const player = new PcmPlayer();
|
||||
playerRef.current = player;
|
||||
player.setVolume(0.75);
|
||||
player.start(); // called from the click gesture
|
||||
unsubRef.current = ws.onPcm((chunk) => {
|
||||
player.push(chunk.userIdHash, chunk.samples);
|
||||
});
|
||||
timerRef.current = setInterval(() => {
|
||||
setLevels(player.getLevels());
|
||||
}, 100);
|
||||
setActive(true);
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
},
|
||||
[ws, stop],
|
||||
);
|
||||
|
||||
const setVolume = useCallback((v: number) => {
|
||||
playerRef.current?.setVolume(v / 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => stop, [stop]);
|
||||
|
||||
return { active, levels, toggle, setVolume };
|
||||
}
|
||||
@@ -7,10 +7,8 @@ export { chatbotApi } from "./chatbot";
|
||||
export { configApi } from "./config";
|
||||
export { dashboardApi } from "./dashboard";
|
||||
export { knowledgeApi } from "./knowledge";
|
||||
export { mediaApi } from "./media";
|
||||
export { messagesApi } from "./messages";
|
||||
export { moderationApi } from "./moderation";
|
||||
export { recordingsApi } from "./recordings";
|
||||
// Re-export server-side fetchers for use inside React Server Components.
|
||||
export {
|
||||
getChannelCultures,
|
||||
@@ -18,4 +16,3 @@ export {
|
||||
getRecentEdits,
|
||||
} from "./server";
|
||||
export { uiStateApi } from "./ui-state";
|
||||
export { voiceApi } from "./voice";
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type { MediaState } from "@/lib/types";
|
||||
|
||||
export const mediaApi = {
|
||||
getStatus: () => orpc.media.status() as unknown as Promise<MediaState>,
|
||||
queue: (source: string, mode: string) =>
|
||||
orpc.media.queue({ source, mode }) as unknown as Promise<MediaState>,
|
||||
skip: () => orpc.media.skip() as unknown as Promise<MediaState>,
|
||||
stop: () => orpc.media.stop() as unknown as Promise<MediaState>,
|
||||
loop: (loop: boolean) =>
|
||||
orpc.media.loop({ loop }) as unknown as Promise<MediaState>,
|
||||
};
|
||||
@@ -1,13 +1,20 @@
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type {
|
||||
AttachmentRecord,
|
||||
Channel,
|
||||
EditHistoryRow,
|
||||
Guild,
|
||||
MessageActivityBucket,
|
||||
MessageRecord,
|
||||
SemanticSearchResult,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const messagesApi = {
|
||||
getGuilds: () => orpc.messages.guilds() as unknown as Promise<Guild[]>,
|
||||
|
||||
getTextChannels: (guildId: string) =>
|
||||
orpc.messages.textChannels({ guildId }) as unknown as Promise<Channel[]>,
|
||||
|
||||
list: (
|
||||
guildId: string,
|
||||
limit?: number,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type { PaginatedRecordings, SpeakerSummary } from "@/lib/types";
|
||||
|
||||
export interface RecordingListParams {
|
||||
limit?: number;
|
||||
channelId?: string;
|
||||
userId?: string;
|
||||
cursor?: string;
|
||||
/** keyword search against transcription + username */
|
||||
q?: string;
|
||||
/** created_at lower bound (epoch ms) */
|
||||
startDate?: number;
|
||||
/** created_at upper bound (epoch ms) */
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
export const recordingsApi = {
|
||||
list: (params: RecordingListParams = {}) =>
|
||||
orpc.recordings.list({
|
||||
limit: params.limit,
|
||||
channelId: params.channelId,
|
||||
userId: params.userId,
|
||||
cursor: params.cursor,
|
||||
q: params.q,
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
}) as unknown as Promise<PaginatedRecordings>,
|
||||
|
||||
delete: (id: string) =>
|
||||
orpc.recordings.delete({ id }) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
summary: () =>
|
||||
orpc.recordings.summary({}) as unknown as Promise<SpeakerSummary[]>,
|
||||
};
|
||||
@@ -25,13 +25,10 @@ import type {
|
||||
GlossaryRow,
|
||||
Guild,
|
||||
HourlyModeration,
|
||||
MediaState,
|
||||
ModerationCoverage,
|
||||
ModerationStats,
|
||||
ModerationTrends,
|
||||
PaginatedModerationActions,
|
||||
PaginatedRecordings,
|
||||
VoiceStatus,
|
||||
} from "@/lib/types";
|
||||
import type { ORPCClient } from "../orpc/types";
|
||||
|
||||
@@ -77,11 +74,6 @@ export async function getUsers(limit = 20) {
|
||||
}) as unknown as Promise<import("@/lib/types").PaginatedUsers>;
|
||||
}
|
||||
|
||||
// ---- Media ----
|
||||
export async function getMediaStatus(): Promise<MediaState> {
|
||||
return serverOrpc().media.status() as unknown as Promise<MediaState>;
|
||||
}
|
||||
|
||||
// ---- Config ----
|
||||
export async function getConfig(): Promise<AppConfig> {
|
||||
return serverOrpc().config.get() as unknown as Promise<AppConfig>;
|
||||
@@ -125,19 +117,9 @@ export async function getCoverage(days = 30) {
|
||||
}) as unknown as ModerationCoverage;
|
||||
}
|
||||
|
||||
// ---- Voice ----
|
||||
// ---- Guilds (from message archive) ----
|
||||
export async function getGuilds(): Promise<Guild[]> {
|
||||
return serverOrpc().voice.guilds() as unknown as Promise<Guild[]>;
|
||||
}
|
||||
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
||||
return serverOrpc().voice.status() as unknown as Promise<VoiceStatus>;
|
||||
}
|
||||
|
||||
// ---- Recordings ----
|
||||
export async function getRecordings(limit = 50): Promise<PaginatedRecordings> {
|
||||
return serverOrpc().recordings.list({
|
||||
limit,
|
||||
}) as unknown as Promise<PaginatedRecordings>;
|
||||
return serverOrpc().messages.guilds() as unknown as Promise<Guild[]>;
|
||||
}
|
||||
|
||||
// ---- Messages (SSR seed for the streaming view) ----
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type { Channel, Guild, VoiceStatus } from "@/lib/types";
|
||||
|
||||
export const voiceApi = {
|
||||
// Guilds
|
||||
getGuilds: () => orpc.voice.guilds() as unknown as Promise<Guild[]>,
|
||||
getTextChannels: (guildId: string) =>
|
||||
orpc.voice.textChannels({ guildId }) as unknown as Promise<Channel[]>,
|
||||
getVoiceChannels: (guildId: string) =>
|
||||
orpc.voice.voiceChannels({ guildId }) as unknown as Promise<Channel[]>,
|
||||
|
||||
// Voice connection
|
||||
getStatus: () => orpc.voice.status() as unknown as Promise<VoiceStatus>,
|
||||
connect: (guildId: string, channelId: string) =>
|
||||
orpc.voice.connect({
|
||||
guildId,
|
||||
channelId,
|
||||
}) as unknown as Promise<VoiceStatus>,
|
||||
disconnect: () => orpc.voice.disconnect() as unknown as Promise<VoiceStatus>,
|
||||
sendCommand: (command: string) =>
|
||||
orpc.voice.command({ command }) as unknown as Promise<unknown>,
|
||||
};
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* Browser mic → Discord voice transmit.
|
||||
*
|
||||
* Pipeline: getUserMedia → AudioContext (48kHz) → AudioWorklet (downsample to
|
||||
* 24kHz mono s16le, apply volume, chunk 20ms) → binary WS frames.
|
||||
*
|
||||
* The backend expects each binary frame to start with a 4-byte magic "PCM\0"
|
||||
* followed by raw Int16LE PCM; it base64s the payload and publishes to Redis,
|
||||
* where the gateway's VoiceTransmitter feeds it into FFmpeg (24kHz mono s16le
|
||||
* → OggOpus) and plays it in the voice channel.
|
||||
*/
|
||||
|
||||
const PCM_MAGIC = new Uint8Array([0x50, 0x43, 0x4d, 0x00]); // "PCM\0"
|
||||
const TARGET_RATE = 24000;
|
||||
const CHUNK_MS = 20;
|
||||
|
||||
// Inline AudioWorklet processor (Blob URL — works with Next static export,
|
||||
// no asset pipeline needed).
|
||||
const WORKLET_SRC = `
|
||||
class PcmDownsampler extends AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super();
|
||||
const opts = options.processorOptions || {};
|
||||
this.targetRate = opts.targetRate || 24000;
|
||||
this.ratio = sampleRate / this.targetRate;
|
||||
this.chunkSamples = Math.floor((this.targetRate * (opts.chunkMs || 20)) / 1000);
|
||||
this.phase = 0;
|
||||
this.buffer = new Int16Array(this.chunkSamples);
|
||||
this.bufferLen = 0;
|
||||
this.volume = typeof opts.volume === 'number' ? opts.volume : 1;
|
||||
this.port.onmessage = (e) => {
|
||||
if (e.data && e.data.type === 'volume') this.volume = e.data.value;
|
||||
};
|
||||
}
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (!input || input.length === 0) return true;
|
||||
// Mixdown: average available channels
|
||||
const chans = input.filter((c) => c && c.length > 0);
|
||||
if (chans.length === 0) return true;
|
||||
const len = chans[0].length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
let s = 0;
|
||||
for (let c = 0; c < chans.length; c++) s += chans[c][i];
|
||||
s /= chans.length;
|
||||
this.phase += 1;
|
||||
if (this.phase >= this.ratio) {
|
||||
this.phase -= this.ratio;
|
||||
const v = Math.max(-1, Math.min(1, s * this.volume));
|
||||
this.buffer[this.bufferLen++] = (v * 32767) | 0;
|
||||
if (this.bufferLen >= this.chunkSamples) {
|
||||
const out = new Int16Array(this.buffer);
|
||||
this.port.postMessage(out.buffer, [out.buffer]);
|
||||
this.buffer = new Int16Array(this.chunkSamples);
|
||||
this.bufferLen = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('pcm-downsampler', PcmDownsampler);
|
||||
`;
|
||||
|
||||
/** Mic access error with user-actionable detail. */
|
||||
export class MicAccessError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly reason:
|
||||
| "not-supported"
|
||||
| "permission-denied"
|
||||
| "no-mic"
|
||||
| "timeout"
|
||||
| "unknown",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MicAccessError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicTransmitterOptions {
|
||||
/** Enable browser-level noise suppression (default: true). */
|
||||
noiseSuppression?: boolean;
|
||||
/** Enable echo cancellation (default: true). */
|
||||
echoCancellation?: boolean;
|
||||
/** Enable auto gain control (default: true). */
|
||||
autoGainControl?: boolean;
|
||||
}
|
||||
|
||||
export class MicTransmitter {
|
||||
private ctx: AudioContext | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
private node: AudioWorkletNode | null = null;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private levelBuf: Float32Array<ArrayBuffer> | null = null;
|
||||
private active = false;
|
||||
private volume = 1;
|
||||
private noiseSuppression = true;
|
||||
|
||||
constructor(
|
||||
private readonly onChunk: (frame: ArrayBuffer) => void,
|
||||
private readonly options: MicTransmitterOptions = {},
|
||||
) {
|
||||
this.noiseSuppression = options.noiseSuppression ?? true;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
get isNoiseSuppressionEnabled(): boolean {
|
||||
return this.noiseSuppression;
|
||||
}
|
||||
|
||||
async start(volume = 1): Promise<void> {
|
||||
if (this.active) return;
|
||||
this.volume = volume;
|
||||
|
||||
// ── Check getUserMedia support ───────────────────────────────────────
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new MicAccessError(
|
||||
"getUserMedia is not available — are you on HTTPS or localhost?",
|
||||
"not-supported",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Request mic with noise suppression constraints ───────────────────
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: this.options.echoCancellation ?? true,
|
||||
noiseSuppression: this.noiseSuppression,
|
||||
autoGainControl: this.options.autoGainControl ?? true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException) {
|
||||
if (
|
||||
err.name === "NotAllowedError" ||
|
||||
err.name === "PermissionDeniedError"
|
||||
) {
|
||||
throw new MicAccessError(
|
||||
"Microphone access denied — allow mic permission in your browser",
|
||||
"permission-denied",
|
||||
);
|
||||
}
|
||||
if (
|
||||
err.name === "NotFoundError" ||
|
||||
err.name === "DevicesNotFoundError"
|
||||
) {
|
||||
throw new MicAccessError(
|
||||
"No microphone found — connect a mic and try again",
|
||||
"no-mic",
|
||||
);
|
||||
}
|
||||
if (err.name === "OverconstrainedError") {
|
||||
throw new MicAccessError(
|
||||
"Microphone does not support the requested constraints",
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
if (err.name === "AbortError" || err.name === "TimeoutError") {
|
||||
throw new MicAccessError(
|
||||
"Microphone access timed out — try again",
|
||||
"timeout",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new MicAccessError(
|
||||
`Failed to access microphone: ${err instanceof Error ? err.message : String(err)}`,
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
|
||||
this.ctx = new AudioContext({ sampleRate: 48000 });
|
||||
|
||||
const blob = new Blob([WORKLET_SRC], { type: "application/javascript" });
|
||||
const workletUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
await this.ctx.audioWorklet.addModule(workletUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(workletUrl);
|
||||
}
|
||||
|
||||
const source = this.ctx.createMediaStreamSource(this.stream);
|
||||
this.node = new AudioWorkletNode(this.ctx, "pcm-downsampler", {
|
||||
processorOptions: {
|
||||
targetRate: TARGET_RATE,
|
||||
chunkMs: CHUNK_MS,
|
||||
volume: this.volume,
|
||||
},
|
||||
});
|
||||
|
||||
this.node.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
|
||||
if (!this.active || !(e.data instanceof ArrayBuffer)) return;
|
||||
const frame = new Uint8Array(PCM_MAGIC.length + e.data.byteLength);
|
||||
frame.set(PCM_MAGIC, 0);
|
||||
frame.set(new Uint8Array(e.data), PCM_MAGIC.length);
|
||||
this.onChunk(frame.buffer);
|
||||
};
|
||||
|
||||
source.connect(this.node);
|
||||
|
||||
// Level metering tap: analyser reads the raw mic (pre-volume) so the UI
|
||||
// shows what the mic actually hears. Silent sink keeps the graph alive.
|
||||
this.analyser = this.ctx.createAnalyser();
|
||||
this.analyser.fftSize = 1024;
|
||||
this.levelBuf = new Float32Array(this.analyser.fftSize);
|
||||
source.connect(this.analyser);
|
||||
|
||||
// Keep the graph alive with an inaudible tail (silent gain) so the
|
||||
// worklet keeps pulling mic data without audible feedback.
|
||||
const silent = this.ctx.createGain();
|
||||
silent.gain.value = 0;
|
||||
this.node.connect(silent);
|
||||
silent.connect(this.ctx.destination);
|
||||
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
/** RMS mic level 0..1 since the last call (drives the live meter UI). */
|
||||
getLevel(): number {
|
||||
if (!this.analyser || !this.levelBuf) return 0;
|
||||
this.analyser.getFloatTimeDomainData(this.levelBuf);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < this.levelBuf.length; i++) sum += this.levelBuf[i] ** 2;
|
||||
return Math.min(1, Math.sqrt(sum / this.levelBuf.length) * 4);
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
this.volume = volume;
|
||||
this.node?.port.postMessage({ type: "volume", value: volume });
|
||||
}
|
||||
|
||||
/** Toggle noise suppression. Requires restart to take effect. */
|
||||
setNoiseSuppression(enabled: boolean): void {
|
||||
this.noiseSuppression = enabled;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.active = false;
|
||||
this.node?.port.postMessage({ type: "volume", value: 0 });
|
||||
this.node?.disconnect();
|
||||
this.node = null;
|
||||
this.analyser?.disconnect();
|
||||
this.analyser = null;
|
||||
this.levelBuf = null;
|
||||
this.stream?.getTracks().forEach((t) => t.stop());
|
||||
this.stream = null;
|
||||
this.ctx?.close().catch(() => {});
|
||||
this.ctx = null;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* Receive Discord voice PCM over the WebSocket and play it through the
|
||||
* browser audio stack.
|
||||
*
|
||||
* Binary frame format (from the gateway, via backend WS):
|
||||
* Byte 0–3: FNV-1a 32-bit userId hash (UInt32LE)
|
||||
* Byte 4+: PCM audio (24kHz mono Int16LE)
|
||||
*
|
||||
* The player keeps one ring buffer per user (2s @ 24kHz), upsamples
|
||||
* 24k → 48k with linear interpolation inside the audio callback, and mixes
|
||||
* every active user into a single mono output. A ScriptProcessorNode is used
|
||||
* because it runs on the main thread — ring buffers need no SharedArrayBuffer
|
||||
* and work without COOP/COEP headers. `start()` must be called from a user
|
||||
* gesture (audio autoplay policy).
|
||||
*/
|
||||
|
||||
const INPUT_RATE = 24000;
|
||||
const OUTPUT_RATE = 48000;
|
||||
const RING_SECONDS = 2;
|
||||
const RING_LEN = INPUT_RATE * RING_SECONDS;
|
||||
|
||||
interface UserRing {
|
||||
data: Float32Array;
|
||||
/** absolute write position (monotonic) */
|
||||
write: number;
|
||||
/** absolute read position as float — advances at 0.5× per output sample */
|
||||
readPos: number;
|
||||
/** last tick where this user produced audio (for stale-ring cleanup) */
|
||||
lastActive: number;
|
||||
}
|
||||
|
||||
export class PcmPlayer {
|
||||
private ctx: AudioContext | null = null;
|
||||
private processor: ScriptProcessorNode | null = null;
|
||||
private master: GainNode | null = null;
|
||||
private rings = new Map<number, UserRing>();
|
||||
private levels = new Map<number, number>();
|
||||
private volume = 0.75;
|
||||
private started = false;
|
||||
|
||||
get isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
|
||||
/** Create the AudioContext + processor. MUST be called from a user gesture. */
|
||||
start(): void {
|
||||
if (this.started) return;
|
||||
const Ctor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
this.ctx = new Ctor({ sampleRate: OUTPUT_RATE });
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = this.volume;
|
||||
this.master.connect(this.ctx.destination);
|
||||
|
||||
this.processor = this.ctx.createScriptProcessor(4096, 0, 1);
|
||||
this.processor.onaudioprocess = (e) => this.tick(e);
|
||||
this.processor.connect(this.master);
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
/** Push one PCM chunk (s16 mono @24kHz) for a user. */
|
||||
push(userIdHash: number, samples: Int16Array): void {
|
||||
if (!this.started || samples.length === 0) return;
|
||||
let ring = this.rings.get(userIdHash);
|
||||
if (!ring) {
|
||||
ring = {
|
||||
data: new Float32Array(RING_LEN),
|
||||
write: 0,
|
||||
readPos: 0,
|
||||
lastActive: Date.now(),
|
||||
};
|
||||
this.rings.set(userIdHash, ring);
|
||||
}
|
||||
ring.lastActive = Date.now();
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
ring.data[ring.write % RING_LEN] = samples[i] / 32768;
|
||||
ring.write++;
|
||||
}
|
||||
// Overflow guard: never let the ring lag more than RING_LEN behind.
|
||||
// This handles both normal drift and extreme backpressure scenarios.
|
||||
const lag = ring.write - ring.readPos;
|
||||
if (lag > RING_LEN - 4096) {
|
||||
ring.readPos = ring.write - RING_LEN + 4096;
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(v: number): void {
|
||||
this.volume = v;
|
||||
if (this.master) this.master.gain.value = v;
|
||||
}
|
||||
|
||||
/** Peak |sample| per user since the last call (drives the UI waveform). */
|
||||
getLevels(): Map<number, number> {
|
||||
return new Map(this.levels);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.processor) {
|
||||
this.processor.disconnect();
|
||||
this.processor.onaudioprocess = null;
|
||||
}
|
||||
if (this.ctx) {
|
||||
void this.ctx.close().catch(() => {});
|
||||
}
|
||||
this.processor = null;
|
||||
this.ctx = null;
|
||||
this.master = null;
|
||||
this.rings.clear();
|
||||
this.levels.clear();
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
private tick(e: AudioProcessingEvent): void {
|
||||
const out = e.outputBuffer.getChannelData(0);
|
||||
out.fill(0);
|
||||
const n = out.length;
|
||||
const step = INPUT_RATE / OUTPUT_RATE; // 0.5
|
||||
const nextLevels = new Map<number, number>();
|
||||
|
||||
for (const [hash, ring] of this.rings) {
|
||||
let level = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const pos = ring.readPos + i * step;
|
||||
if (pos + 1 >= ring.write) break;
|
||||
const i0 = Math.floor(pos);
|
||||
const frac = pos - i0;
|
||||
const a = ring.data[i0 % RING_LEN];
|
||||
const b = ring.data[(i0 + 1) % RING_LEN];
|
||||
const v = a + (b - a) * frac;
|
||||
out[i] += v;
|
||||
const av = Math.abs(v);
|
||||
if (av > level) level = av;
|
||||
}
|
||||
ring.readPos += n * step;
|
||||
if (ring.readPos > ring.write) ring.readPos = ring.write;
|
||||
if (level > 0.001) {
|
||||
ring.lastActive = Date.now();
|
||||
nextLevels.set(hash, level);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop users silent for >5s so the ring map doesn't grow unbounded.
|
||||
const now = Date.now();
|
||||
for (const [hash, ring] of this.rings) {
|
||||
if (now - ring.lastActive > 5000) this.rings.delete(hash);
|
||||
}
|
||||
|
||||
this.levels = nextLevels;
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Client-side audio export helpers (no server/ffmpeg needed).
|
||||
*
|
||||
* Fetches a remote MP3/OGG, decodes it with the browser's codec stack
|
||||
* (Web Audio API), and re-encodes as 16-bit PCM WAV — the format Audacity
|
||||
* opens cleanest for editing. Also supports concatenating several decoded
|
||||
* clips into a single WAV (e.g. every recording from one filtered user).
|
||||
*/
|
||||
|
||||
/** Get a shared AudioContext (created lazily; kept suspended for decode). */
|
||||
function getCtx(): AudioContext {
|
||||
const Ctor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!Ctor) throw new Error("Web Audio API is not available in this browser");
|
||||
return new Ctor();
|
||||
}
|
||||
|
||||
/** Fetch a remote audio URL and decode it to an AudioBuffer. */
|
||||
export async function decodeAudio(url: string): Promise<AudioBuffer> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch audio (${res.status})`);
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const ctx = getCtx();
|
||||
try {
|
||||
return await ctx.decodeAudioData(arrayBuffer);
|
||||
} finally {
|
||||
// free the context resources; decodeAudioData is standalone
|
||||
void ctx.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an AudioBuffer to a 16-bit PCM WAV Blob (RIFF).
|
||||
* Multi-channel buffers are preserved (interleaved). Audacity imports this
|
||||
* as one track per channel.
|
||||
*/
|
||||
export function audioBufferToWav(buf: AudioBuffer): Blob {
|
||||
const numChannels = Math.max(1, buf.numberOfChannels);
|
||||
const sampleRate = buf.sampleRate;
|
||||
const frames = buf.length;
|
||||
const bytesPerSample = 2; // 16-bit
|
||||
const blockAlign = numChannels * bytesPerSample;
|
||||
const dataSize = frames * blockAlign;
|
||||
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const writeString = (offset: number, str: string): void => {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
// RIFF header
|
||||
writeString(0, "RIFF");
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(8, "WAVE");
|
||||
writeString(12, "fmt ");
|
||||
view.setUint32(16, 16, true); // fmt chunk size
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, 16, true); // bits per sample
|
||||
writeString(36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// Interleave channel data into the sample block. Clamp to [-1, 1].
|
||||
let offset = 44;
|
||||
for (let frame = 0; frame < frames; frame++) {
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
const data = buf.getChannelData(ch);
|
||||
const s = Math.max(-1, Math.min(1, data[frame]));
|
||||
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate several decoded clips into one mono AudioBuffer (channel 0).
|
||||
* Uses the highest sample rate among inputs. Returns null if `buffers` is empty.
|
||||
*/
|
||||
export function concatAudioBuffers(buffers: AudioBuffer[]): AudioBuffer | null {
|
||||
if (buffers.length === 0) return null;
|
||||
const rate = Math.max(...buffers.map((b) => b.sampleRate));
|
||||
const totalFrames = buffers.reduce((acc, b) => acc + b.length, 0);
|
||||
const ctx = getCtx();
|
||||
const out = ctx.createBuffer(1, totalFrames, rate);
|
||||
const target = out.getChannelData(0);
|
||||
|
||||
let write = 0;
|
||||
for (const b of buffers) {
|
||||
const src = b.getChannelData(0);
|
||||
// Resample if the source rate differs from the target rate.
|
||||
if (b.sampleRate === rate) {
|
||||
target.set(src, write);
|
||||
write += src.length;
|
||||
} else {
|
||||
const step = b.sampleRate / rate;
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
target[Math.floor(write + i * step)] = src[i];
|
||||
}
|
||||
write += Math.floor(src.length / step);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trigger a browser download for a Blob. */
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// revoke on next tick so the download has a chance to start
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* FNV-1a 32-bit hash — same hash the gateway uses to tag PCM frames.
|
||||
* Shared between use-voice hooks and ambient-canvas.
|
||||
*/
|
||||
export function hashUserId(userId: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash ^= userId.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
@@ -7,9 +7,7 @@ export interface Guild {
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "voice" | "text";
|
||||
/** Whether the selfbot account can actually join this voice channel. */
|
||||
joinable?: boolean;
|
||||
type: "text";
|
||||
}
|
||||
|
||||
/** Shape of the /api/config response (camelCase keys from backend). */
|
||||
@@ -21,10 +19,7 @@ export interface AppConfig {
|
||||
backlogSyncBatchSize?: number;
|
||||
retentionMessagesDays?: number;
|
||||
retentionAttachmentsDays?: number;
|
||||
retentionVoiceDays?: number;
|
||||
autoDeleteFlaggedEnabled?: boolean;
|
||||
aiAnalysisEnabled?: boolean;
|
||||
voiceGuildId?: string | null;
|
||||
voiceChannelId?: string | null;
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user