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:
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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
export * from "./dashboard";
|
||||
export * from "./guild";
|
||||
export * from "./knowledge";
|
||||
export * from "./media";
|
||||
export * from "./message";
|
||||
export * from "./moderation";
|
||||
export * from "./recording";
|
||||
export * from "./ui";
|
||||
export * from "./voice";
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export type MediaMode = "music" | "screen";
|
||||
|
||||
export interface MediaItem {
|
||||
id?: string | null;
|
||||
source: string;
|
||||
title: string;
|
||||
mode?: MediaMode | null;
|
||||
durationMs?: number | null;
|
||||
thumbnailUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
activeMode?: MediaMode | null;
|
||||
musicVolume: number;
|
||||
loop: boolean;
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
export interface VoiceRecording {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url?: string | null;
|
||||
guild_id?: string | null;
|
||||
channel_id?: string | null;
|
||||
channel_name?: string | null;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
download_url?: string | null;
|
||||
upload_status: string;
|
||||
upload_error?: string | null;
|
||||
created_at: number;
|
||||
uploaded_at?: number | null;
|
||||
transcription?: string | null;
|
||||
}
|
||||
|
||||
/** True when the recording file is video (camera/screen-share MP4). */
|
||||
export function isVideoRecording(r: Pick<VoiceRecording, "filename">): boolean {
|
||||
return /\.(mp4|webm|mov|h264)$/i.test(r.filename);
|
||||
}
|
||||
|
||||
export interface PaginatedRecordings {
|
||||
items: VoiceRecording[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** recordings.summary — per-speaker leaderboard. */
|
||||
export interface SpeakerSummary {
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
clips: number;
|
||||
est_duration_s: number;
|
||||
words: number;
|
||||
transcribed: number;
|
||||
last_at: number;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export interface GuildVoiceEntry {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
connectedAt: number;
|
||||
}
|
||||
|
||||
export interface VoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId?: string | null;
|
||||
activeChannelId?: string | null;
|
||||
activeChannelName?: string | null;
|
||||
connections: GuildVoiceEntry[];
|
||||
/**
|
||||
* Authoritative shared voice snapshot — who is present / speaking right
|
||||
* now, aggregated server-side from the gateway's `voice_active_user`
|
||||
* deltas. All browsers converge on this same list.
|
||||
*/
|
||||
activeSpeakers?: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export interface ActiveSpeaker {
|
||||
userId: string;
|
||||
username: string;
|
||||
avatar?: string | null;
|
||||
speaking: boolean;
|
||||
/** Epoch ms of most recent activity. Stale speakers are auto-expired. */
|
||||
lastActiveAt?: number;
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
ActiveSpeaker,
|
||||
MediaState,
|
||||
MessageRecord,
|
||||
ModerationAction,
|
||||
VoiceRecording,
|
||||
} from "@/lib/types";
|
||||
import type { MessageRecord, ModerationAction } from "@/lib/types";
|
||||
|
||||
// ── Connection Status ──────────────────────────────────────
|
||||
|
||||
@@ -46,19 +40,6 @@ export interface WsEventMap {
|
||||
message_snapshot_end: { sent: number; error?: boolean };
|
||||
attachment_created: unknown;
|
||||
attachment_uploaded: unknown;
|
||||
voice_recording_started: unknown;
|
||||
voice_recording_stopped: unknown;
|
||||
voice_recording_uploaded: VoiceRecording;
|
||||
voice_active_user: ActiveSpeaker;
|
||||
/**
|
||||
* Authoritative shared live-voice snapshot — `{ activeSpeakers: [...] }`.
|
||||
* The backend sends this on WS connect (initial state) and clients replace
|
||||
* their local list wholesale so every user converges on the same state.
|
||||
*/
|
||||
voice_state: { activeSpeakers: ActiveSpeaker[] };
|
||||
/** NOT delivered as JSON — arrives only via onPcm() binary handler as PcmChunk */
|
||||
voice_pcm_data: never;
|
||||
voice_analyzed: unknown;
|
||||
analysis_queue_status: unknown;
|
||||
reaction_added: unknown;
|
||||
reaction_removed: unknown;
|
||||
@@ -71,7 +52,6 @@ export interface WsEventMap {
|
||||
guild_member_removed: unknown;
|
||||
/** Live moderation action broadcast (gateway → Redis → backend → WS). */
|
||||
moderation_action: ModerationAction;
|
||||
media_state: MediaState;
|
||||
user_state: unknown;
|
||||
ui_state: unknown;
|
||||
heartbeat: unknown;
|
||||
|
||||
Reference in New Issue
Block a user