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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user