feat(frontend): voice stage-orbit, media queue-spiral & recordings timeline-ring scenes

This commit is contained in:
asepharyana
2026-08-24 15:44:10 +07:00
parent 0581dc3485
commit 84a766db0c
3 changed files with 619 additions and 477 deletions
@@ -1,5 +1,10 @@
"use client"; "use client";
/**
* Media scene — the queue becomes a spiral of nodes on the stage; the
* player console (disc + transport + URL bar) floats bottom-left, and
* the up-next list docks right.
*/
import { import {
ListMusic, ListMusic,
Play, Play,
@@ -9,15 +14,14 @@ import {
Square, Square,
Volume2, Volume2,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context"; import { useAmbient } from "@/components/ambient/ambient-context";
import { Button, GlassPanel, Input, toast } from "@/components/primitives"; import { Button, Input, toast } from "@/components/primitives";
import { ErrorState, SkeletonHero } from "@/components/shared";
import { import {
ErrorState, useSceneFocusSetter,
SectionHeader, useScenePublish,
SkeletonHero, } from "@/components/shell/scene-graph-context";
SkeletonPanel,
} from "@/components/shared";
import { import {
useMediaLoop, useMediaLoop,
useMediaQueue, useMediaQueue,
@@ -26,11 +30,37 @@ import {
useMediaStop, useMediaStop,
useMediaWsSync, useMediaWsSync,
} from "@/hooks"; } from "@/hooks";
import type { ConstellationGraph } from "@/lib/constellation/graph";
import { formatDuration } from "@/lib/format"; import { formatDuration } from "@/lib/format";
import type { MediaState } from "@/lib/types"; import type { MediaState } from "@/lib/types";
import { staggerDelay } from "@/lib/utils"; import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
function queueGraph(media: MediaState | undefined): ConstellationGraph {
const items = (media?.queue ?? []).slice(0, 24);
return {
nodes: [
{
id: "now-playing",
label: media?.current?.title ?? "media",
kind: "guild",
value: 1,
},
...items.map((it, i) => ({
id: `track:${i}:${it.source}`,
label: it.title,
kind: "media" as const,
value: Math.max(0.2, 0.8 - i * 0.05),
href: undefined,
})),
],
edges: items.map((it, i) => ({
source: "now-playing",
target: `track:${i}:${it.source}`,
})),
};
}
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const ws = useWebSocket(); const ws = useWebSocket();
const { data: media, isLoading, error } = useMediaState(initialStatus); const { data: media, isLoading, error } = useMediaState(initialStatus);
@@ -40,6 +70,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const loop = useMediaLoop(); const loop = useMediaLoop();
useMediaWsSync(ws); useMediaWsSync(ws);
const ambient = useAmbient(); const ambient = useAmbient();
const publish = useScenePublish();
const setFocus = useSceneFocusSetter();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
@@ -51,6 +83,19 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
0, 0,
); );
const graph = useMemo(() => queueGraph(media), [media]);
useEffect(() => {
publish({ graph, focus: null });
}, [graph, publish]);
useEffect(
() => () => {
publish({ graph: { nodes: [], edges: [] }, focus: null });
setFocus(null);
},
[publish, setFocus],
);
const tone = playing ? "signal" : queueList.length ? "amber" : "signal"; const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
useEffect(() => { useEffect(() => {
ambient.set( ambient.set(
@@ -80,126 +125,105 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
}; };
if (error && !media) return <ErrorState error={error} />; if (error && !media) return <ErrorState error={error} />;
if (!media && isLoading) if (!media && isLoading) return <SkeletonHero />;
return (
<div className="space-y-5">
<SkeletonHero />
<SkeletonPanel rows={4} />
</div>
);
return ( return (
<div className="space-y-5"> <div className="min-h-full">
<GlassPanel glow className="relative overflow-hidden"> {/* Now playing — top-center whisper */}
<div className="scan-line absolute inset-x-0 top-0" /> <section
<div className="flex flex-col gap-5 sm:flex-row sm:items-center"> className="pointer-events-none absolute inset-x-0 top-16 hidden justify-center px-6 md:flex"
aria-label="Now playing"
>
<div className="pointer-events-auto flex max-w-[52vw] items-center gap-3 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 px-4 py-2 backdrop-blur-md">
<div <div
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`} className={`flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[var(--color-hairline)] ${playing ? "animate-spin-disc" : ""}`}
> >
<div className="flex size-28 items-center justify-center overflow-hidden rounded-full bg-canvas/60"> {current?.thumbnailUrl ? (
{current?.thumbnailUrl ? ( // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist <img
<img src={current.thumbnailUrl}
src={current.thumbnailUrl} alt=""
alt="" className="size-full object-cover"
className="size-full object-cover" loading="lazy"
loading="lazy" />
/> ) : (
) : ( <ListMusic className="size-4 text-signal" />
<ListMusic className="size-10 text-signal" /> )}
)}
</div>
</div> </div>
<div className="min-w-0">
<div className="min-w-0 flex-1"> <p className="eyebrow flex items-center gap-2">
<div className="eyebrow mb-1 flex items-center gap-2">
{playing ? ( {playing ? (
<> <span className="flex h-3 items-end gap-[2px]" aria-hidden>
<span aria-hidden className="flex h-3 items-end gap-[2px]"> {[0, 1, 2].map((i) => (
{[0, 1, 2].map((i) => ( <span
<span key={`eq-${i}`}
key={`eq-${i}`} className="w-[3px] animate-eq rounded-full bg-signal"
className="w-[3px] animate-eq rounded-full bg-signal" style={{ animationDelay: `${i * 160}ms`, height: "100%" }}
style={{ />
animationDelay: `${i * 160}ms`, ))}
height: "100%",
}}
/>
))}
</span>
<span className="text-signal">Now playing</span>
</>
) : current ? (
"Paused"
) : (
"Nothing queued"
)}
</div>
<h2 className="display text-balance text-2xl text-ink">
{current?.title ?? "Nothing queued"}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
{current?.source && (
<span className="mono truncate">{current.source}</span>
)}
{current?.mode && (
<span className="pill capitalize">{current.mode}</span>
)}
{formatDuration(current?.durationMs) && (
<span className="mono">
{formatDuration(current?.durationMs)}
</span> </span>
)} ) : null}
</div> {playing ? "Now playing" : current ? "Paused" : "Nothing queued"}
<div className="mt-4 flex flex-wrap items-center gap-2"> </p>
<Button <p className="truncate text-sm text-[var(--color-ink)]">
variant="primary" {current?.title ?? "Nothing queued"}
size="sm" {formatDuration(current?.durationMs)
onClick={onPlay} ? ` · ${formatDuration(current?.durationMs)}`
disabled={queue.isPending} : ""}
> </p>
<Play className="size-4" /> Queue & play
</Button>
<Button
variant="outline"
size="sm"
onClick={() => skip.mutate()}
disabled={skip.isPending}
>
<SkipForward className="size-4" /> Skip
</Button>
<Button
variant="outline"
size="sm"
onClick={() => stop.mutate()}
disabled={stop.isPending}
>
<Square className="size-4" /> Stop
</Button>
<Button
variant={media?.loop ? "primary" : "outline"}
size="sm"
onClick={() => loop.mutate(!media?.loop)}
aria-pressed={!!media?.loop}
>
<Repeat className="size-4" /> Loop
</Button>
</div>
</div> </div>
</div> </div>
</section>
<div className="mt-5 flex flex-wrap items-center gap-3"> {/* Console — bottom-left */}
<div className="relative min-w-0 flex-1"> <section
<Input className="pointer-events-auto absolute bottom-20 left-5 z-20 w-[min(26rem,92vw)] space-y-2.5 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl lg:bottom-24"
placeholder="Paste a YouTube / music URL…" aria-label="Player console"
value={url} >
onChange={(e) => setUrl(e.target.value)} <div className="relative">
onKeyDown={(e) => e.key === "Enter" && onPlay()} <Input
/> placeholder="Paste a YouTube / music URL…"
</div> value={url}
<div className="flex shrink-0 items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"> onChange={(e) => setUrl(e.target.value)}
<Volume2 className="size-4 text-ink-faint" /> onKeyDown={(e) => e.key === "Enter" && onPlay()}
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/10"> />
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={onPlay}
disabled={queue.isPending}
>
<Play className="size-4" /> Queue &amp; play
</Button>
<Button
variant="outline"
size="sm"
onClick={() => skip.mutate()}
disabled={skip.isPending}
>
<SkipForward className="size-4" /> Skip
</Button>
<Button
variant="outline"
size="sm"
onClick={() => stop.mutate()}
disabled={stop.isPending}
>
<Square className="size-4" /> Stop
</Button>
<Button
variant={media?.loop ? "primary" : "outline"}
size="sm"
onClick={() => loop.mutate(!media?.loop)}
aria-pressed={!!media?.loop}
>
<Repeat className="size-4" /> Loop
</Button>
<div className="ml-auto flex items-center gap-2">
<Volume2 className="size-4 text-[var(--color-ink-faint)]" />
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-white/10">
<div <div
className="h-full rounded-full bg-signal/70" className="h-full rounded-full bg-signal/70"
style={{ style={{
@@ -207,87 +231,89 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
}} }}
/> />
</div> </div>
<span className="mono w-9 text-right text-[0.65rem] text-ink-faint"> <span className="w-9 text-right font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
{Math.round((media?.musicVolume ?? 0) * 100)}% {Math.round((media?.musicVolume ?? 0) * 100)}%
</span> </span>
</div> </div>
</div> </div>
</GlassPanel> </section>
<GlassPanel> {/* Up next — right dock */}
<SectionHeader <section
eyebrow="up next" className="pointer-events-auto absolute bottom-20 right-5 top-28 hidden w-[min(24rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
title="Queue" aria-label="Up next"
action={ >
<span className="mono text-xs text-ink-faint"> <div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
{queueList.length} track{queueList.length === 1 ? "" : "s"} <span className="eyebrow">up next</span>
{queueTotal && ` · ${formatDuration(queueTotal)}`} <span className="font-mono text-xs text-[var(--color-ink-faint)]">
</span> {queueList.length} track{queueList.length === 1 ? "" : "s"}
} {queueTotal ? ` · ${formatDuration(queueTotal)}` : ""}
/> </span>
{queueList.length === 0 ? ( </div>
<div className="flex flex-col items-center gap-2 py-10 text-center"> <div className="min-h-0 flex-1 overflow-y-auto p-2">
<Radio className="size-6 text-ink-faint" /> {queueList.length === 0 ? (
<div className="text-sm text-ink-soft">Queue is empty</div> <div className="flex flex-col items-center gap-2 py-10 text-center">
<div className="text-xs text-ink-faint"> <Radio className="size-6 text-[var(--color-ink-faint)]" />
Paste a URL above to start playback. <p className="text-sm text-[var(--color-ink-soft)]">
Queue is empty
</p>
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
Paste a URL to start playback.
</p>
</div> </div>
</div> ) : (
) : ( <div className="space-y-1.5">
<div className="space-y-2"> {queueList.map((item, i) => {
{queueList.map((item, i) => { const isNext = i === 0 && playing;
const isNext = i === 0 && playing; return (
return ( <div
<div key={`${item.source}-${i}`}
key={`${item.source}-${i}`} className={`animate-stagger flex items-center gap-3 rounded-xl border px-3 py-2 ${
className={`animate-stagger flex items-center gap-3 rounded-[10px] border px-3 py-2.5 ${ isNext
isNext ? "border-signal/40 bg-signal/[0.07]"
? "border-signal/40 bg-signal/[0.07]" : "border-[var(--color-hairline)]"
: "border-hairline bg-white/5" }`}
}`} style={staggerDelay(i)}
style={staggerDelay(i)} >
> <span className="w-5 font-mono text-xs text-[var(--color-ink-faint)]">
<span className="mono w-5 text-ink-faint">{i + 1}</span> {i + 1}
{item.thumbnailUrl ? (
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
<img
src={item.thumbnailUrl}
alt=""
className="size-9 shrink-0 rounded-md object-cover"
loading="lazy"
/>
) : (
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5">
<ListMusic className="size-4 text-ink-faint" />
</span> </span>
)} {item.thumbnailUrl ? (
<div className="min-w-0 flex-1"> // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
<div className="truncate text-sm text-ink"> <img
{item.title} src={item.thumbnailUrl}
alt=""
className="size-9 shrink-0 rounded-md object-cover"
loading="lazy"
/>
) : (
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-[var(--color-hairline)]">
<ListMusic className="size-4 text-[var(--color-ink-faint)]" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-[var(--color-ink)]">
{item.title}
</p>
<p className="truncate font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
{item.source}
</p>
</div> </div>
<div className="mono truncate text-[0.65rem] text-ink-faint"> {isNext ? (
{item.source} <span className="shrink-0 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 font-mono text-[0.6rem] text-signal">
</div> up next
</div> </span>
{isNext && ( ) : null}
<span className="inline-flex shrink-0 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"> <span className="w-10 text-right font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
up next
</span>
)}
<span className="pill hidden capitalize sm:inline-flex">
{item.mode ?? "music"}
</span>
{formatDuration(item.durationMs) && (
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
{formatDuration(item.durationMs)} {formatDuration(item.durationMs)}
</span> </span>
)} </div>
</div> );
); })}
})} </div>
</div> )}
)} </div>
</GlassPanel> </section>
</div> </div>
); );
} }
@@ -1,18 +1,25 @@
"use client"; "use client";
/**
* Recordings scene — voice clips orbit as a timeline ring on the stage;
* the clip archive docks right with inline audio players, playing clip
* highlighted both in the dock and on the sky.
*/
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react"; import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context"; import { useAmbient } from "@/components/ambient/ambient-context";
import { import {
Avatar, Avatar,
Badge, Badge,
Button, Button,
GlassCard,
GlassPanel,
Skeleton, Skeleton,
toast, toast,
} from "@/components/primitives"; } from "@/components/primitives";
import { EmptyState, ErrorState, SectionHeader } from "@/components/shared"; import { EmptyState, ErrorState } from "@/components/shared";
import {
useSceneFocusSetter,
useScenePublish,
} from "@/components/shell/scene-graph-context";
import { import {
NowPlayingChip, NowPlayingChip,
RecordingAudioPlayer, RecordingAudioPlayer,
@@ -22,11 +29,29 @@ import {
useRecordings, useRecordings,
useRecordingsWsSync, useRecordingsWsSync,
} from "@/hooks"; } from "@/hooks";
import type { ConstellationGraph } from "@/lib/constellation/graph";
import { formatBytes, formatRelativeTime } from "@/lib/format"; import { formatBytes, formatRelativeTime } from "@/lib/format";
import type { VoiceRecording } from "@/lib/types"; import type { VoiceRecording } from "@/lib/types";
import { staggerDelay } from "@/lib/utils"; import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
function recordingsGraph(items: VoiceRecording[]): ConstellationGraph {
const recent = items.slice(0, 28);
return {
nodes: [
{ id: "archive", label: "archive", kind: "guild", value: 1 },
...recent.map((r) => ({
id: `rec:${r.id}`,
label: r.username,
kind: "media" as const,
value: 0.5,
href: undefined,
})),
],
edges: recent.map((r) => ({ source: "archive", target: `rec:${r.id}` })),
};
}
export function RecordingsView({ export function RecordingsView({
initialItems, initialItems,
}: { }: {
@@ -37,8 +62,25 @@ export function RecordingsView({
const del = useDeleteRecording(); const del = useDeleteRecording();
useRecordingsWsSync(ws); useRecordingsWsSync(ws);
const ambient = useAmbient(); const ambient = useAmbient();
const publish = useScenePublish();
const setFocus = useSceneFocusSetter();
const [playingId, setPlayingId] = useState<string | null>(null); const [playingId, setPlayingId] = useState<string | null>(null);
const list = items ?? [];
const graph = useMemo(() => recordingsGraph(list), [list]);
useEffect(() => {
publish({ graph, focus: playingId ? `rec:${playingId}` : null });
}, [graph, playingId, publish]);
useEffect(
() => () => {
publish({ graph: { nodes: [], edges: [] }, focus: null });
setFocus(null);
},
[publish, setFocus],
);
useEffect(() => { useEffect(() => {
ambient.set("signal", 0.3, "recordings"); ambient.set("signal", 0.3, "recordings");
}, [ambient]); }, [ambient]);
@@ -59,14 +101,17 @@ export function RecordingsView({
if (error && !items) return <ErrorState error={error} />; if (error && !items) return <ErrorState error={error} />;
if (!items && isLoading) if (!items && isLoading)
return ( return (
<GlassPanel> <div className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 p-4 backdrop-blur-xl md:inset-x-auto md:right-5 md:top-28 md:w-[min(30rem,92vw)]">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<Skeleton className="h-4 w-28" /> <Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-12" /> <Skeleton className="h-3 w-12" />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<GlassCard key={i} className="flex flex-col gap-3"> <div
key={i}
className="flex flex-col gap-3 rounded-xl border border-[var(--color-hairline)] p-3"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-full" /> <Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
@@ -75,118 +120,168 @@ export function RecordingsView({
</div> </div>
</div> </div>
<Skeleton className="h-9 w-full" /> <Skeleton className="h-9 w-full" />
</GlassCard> </div>
))} ))}
</div> </div>
</GlassPanel> </div>
); );
return ( return (
<GlassPanel> <div className="min-h-full">
<SectionHeader {/* Archive dock — right */}
eyebrow="voice captures" <section
title="Recordings" className="pointer-events-auto absolute bottom-20 right-5 top-16 hidden w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
action={ aria-label="Voice captures"
<span className="mono text-xs text-ink-faint"> >
{(items ?? []).length} clips <div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
<span className="eyebrow">voice captures</span>
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
{list.length} clips
</span> </span>
}
/>
{(items ?? []).length === 0 ? (
<EmptyState
icon={<Headphones className="size-7" />}
title="No recordings"
description="Voice clips captured by the bot appear here."
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(items ?? []).map((r, i) => {
const up = uploadStatus(r);
const isPlaying = playingId === r.id;
return (
<GlassCard
key={r.id}
className={`animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06] ${
isPlaying
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
: ""
}`}
style={staggerDelay(i)}
>
<div className="flex items-center gap-3">
<Avatar src={r.avatar_url} name={r.username} size={38} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-ink">
{r.username}
</div>
<div className="flex items-center gap-1.5 text-[0.65rem] text-ink-faint">
<Hash className="size-3" />
<span className="truncate">
{r.channel_name ?? "voice"}
</span>
<span className="text-ink-faint/60">·</span>
<span className="mono">
{formatRelativeTime(r.created_at)}
</span>
</div>
</div>
{isPlaying && <NowPlayingChip />}
{up && !isPlaying && <Badge tone={up.tone}>{up.label}</Badge>}
<span className="mono text-[0.65rem] text-ink-faint">
{formatBytes(r.size_bytes)}
</span>
</div>
{r.download_url ? (
<RecordingAudioPlayer
src={r.download_url}
label={`Voice recording by ${r.username}`}
onPlayStateChange={(active) =>
setPlayingId((prev) => {
if (active) return r.id;
// Only clear if THIS card was the one playing.
return prev === r.id ? null : prev;
})
}
/>
) : (
<div className="flex items-center gap-1.5 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
<Loader2 className="size-3.5 animate-spin" />
{r.upload_status === "pending"
? "Upload pending…"
: r.upload_error
? r.upload_error
: "Processing…"}
</div>
)}
<div className="flex items-center gap-2">
{r.download_url && (
<a
href={r.download_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
>
<Download className="size-3.5" /> Download
</a>
)}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => onDelete(r.id)}
disabled={del.isPending}
>
<Trash2 className="size-3.5" /> Delete
</Button>
</div>
</GlassCard>
);
})}
</div> </div>
)} <div className="min-h-0 flex-1 overflow-y-auto p-2.5">
</GlassPanel> {list.length === 0 ? (
<EmptyState
icon={<Headphones className="size-6" />}
title="No recordings"
description="Voice clips captured by the bot appear here."
/>
) : (
<div className="space-y-2">
{list.map((r, i) => {
const up = uploadStatus(r);
const isPlaying = playingId === r.id;
return (
<article
key={r.id}
className={`animate-stagger flex flex-col gap-2.5 rounded-xl border p-3 transition-colors ${
isPlaying
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
: "border-[var(--color-hairline)] hover:bg-white/[0.04]"
}`}
style={staggerDelay(i)}
>
<div className="flex items-center gap-3">
<Avatar src={r.avatar_url} name={r.username} size={34} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-[var(--color-ink)]">
{r.username}
</p>
<p className="flex items-center gap-1.5 font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
<Hash className="size-3" />
<span className="truncate">
{r.channel_name ?? "voice"}
</span>
<span>·</span>
<span>{formatRelativeTime(r.created_at)}</span>
</p>
</div>
{isPlaying ? <NowPlayingChip /> : null}
{up && !isPlaying ? (
<Badge tone={up.tone}>{up.label}</Badge>
) : null}
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
{formatBytes(r.size_bytes)}
</span>
</div>
{r.download_url ? (
<RecordingAudioPlayer
src={r.download_url}
label={`Voice recording by ${r.username}`}
onPlayStateChange={(active) =>
setPlayingId((prev) => {
if (active) return r.id;
return prev === r.id ? null : prev;
})
}
/>
) : (
<p className="flex items-center gap-1.5 rounded-lg px-1 py-1 font-mono text-xs text-[var(--color-ink-faint)]">
<Loader2 className="size-3.5 animate-spin" />
{r.upload_status === "pending"
? "Upload pending…"
: r.upload_error
? r.upload_error
: "Processing…"}
</p>
)}
<div className="flex items-center gap-2">
{r.download_url ? (
<a
href={r.download_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-hairline)] px-2.5 py-1 font-mono text-xs text-[var(--color-ink-soft)] hover:border-signal/40 hover:text-[var(--color-ink)]"
>
<Download className="size-3.5" /> Download
</a>
) : null}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => onDelete(r.id)}
disabled={del.isPending}
>
<Trash2 className="size-3.5" /> Delete
</Button>
</div>
</article>
);
})}
</div>
)}
</div>
</section>
{/* Mobile dock */}
<section
className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-2.5 backdrop-blur-xl md:hidden"
aria-label="Voice captures mobile"
>
{list.length === 0 ? (
<EmptyState title="No recordings" />
) : (
list.map((r) => (
<article
key={r.id}
className={`mb-2 flex flex-col gap-2 rounded-xl border p-3 ${
playingId === r.id
? "border-signal/40"
: "border-[var(--color-hairline)]"
}`}
>
<p className="truncate text-sm font-semibold text-[var(--color-ink)]">
{r.username} · {formatRelativeTime(r.created_at)}
</p>
{r.download_url ? (
<RecordingAudioPlayer
src={r.download_url}
label={`Voice recording by ${r.username}`}
onPlayStateChange={(active) =>
setPlayingId((prev) =>
active ? r.id : prev === r.id ? null : prev,
)
}
/>
) : (
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
processing
</p>
)}
</article>
))
)}
</section>
{/* Hint */}
<p className="pointer-events-none absolute bottom-24 left-5 hidden max-w-xs font-mono text-xs text-[var(--color-ink-faint)] lg:block">
setiap klip adalah simpel di cincin timeline yang sedang play menyala
di langit
</p>
</div>
); );
} }
@@ -1,5 +1,10 @@
"use client"; "use client";
/**
* Voice scene — the stage node sits center; live speakers orbit it with
* glow (published to the constellation). Controls float bottom-left,
* connections whisper right, and the mic/listen meters stay docked.
*/
import { import {
Headphones, Headphones,
Mic, Mic,
@@ -9,17 +14,16 @@ import {
Volume2, Volume2,
Waves, Waves,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context"; import { useAmbient } from "@/components/ambient/ambient-context";
import { Equalizer } from "@/components/charts"; import { Equalizer } from "@/components/charts";
import { Button, GlassPanel, toast } from "@/components/primitives"; import { Button, toast } from "@/components/primitives";
import { import { EmptyState, ErrorState } from "@/components/shared";
EmptyState,
ErrorState,
SectionHeader,
SkeletonPanel,
} from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker"; import { GuildChannelPicker } from "@/components/shared/guild-picker";
import {
useSceneFocusSetter,
useScenePublish,
} from "@/components/shell/scene-graph-context";
import { VoiceStage } from "@/components/voice/voice-stage"; import { VoiceStage } from "@/components/voice/voice-stage";
import { import {
useMicTransmit, useMicTransmit,
@@ -29,9 +33,37 @@ import {
useVoiceListen, useVoiceListen,
useVoiceStatus, useVoiceStatus,
} from "@/hooks"; } from "@/hooks";
import type { ConstellationGraph } from "@/lib/constellation/graph";
import type { Guild, VoiceStatus } from "@/lib/types"; import type { Guild, VoiceStatus } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
function speakerGraph(
speakers: {
userId: string;
username: string;
speaking: boolean;
level?: number;
}[],
): ConstellationGraph {
return {
nodes: [
{ id: "stage", label: "voice stage", kind: "guild", value: 1 },
...speakers.map((sp) => ({
id: `speaker:${sp.userId}`,
label: sp.username,
kind: "speaker" as const,
value: sp.speaking ? 0.8 : 0.4,
href: undefined,
meta: { speaking: sp.speaking },
})),
],
edges: speakers.map((sp) => ({
source: "stage",
target: `speaker:${sp.userId}`,
})),
};
}
export function VoiceView({ export function VoiceView({
initialStatus, initialStatus,
initialGuilds, initialGuilds,
@@ -47,6 +79,8 @@ export function VoiceView({
const listen = useVoiceListen(ws); const listen = useVoiceListen(ws);
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers); const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
const ambient = useAmbient(); const ambient = useAmbient();
const publish = useScenePublish();
const setFocus = useSceneFocusSetter();
const [guildId, setGuildId] = useState<string | null>( const [guildId, setGuildId] = useState<string | null>(
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null, initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
@@ -63,6 +97,19 @@ export function VoiceView({
return unsub; return unsub;
}, [subscribe, ws]); }, [subscribe, ws]);
const graph = useMemo(() => speakerGraph(speakers), [speakers]);
useEffect(() => {
publish({ graph, focus: null });
}, [graph, publish]);
useEffect(
() => () => {
publish({ graph: { nodes: [], edges: [] }, focus: null });
setFocus(null);
},
[publish, setFocus],
);
useEffect(() => { useEffect(() => {
if (status?.connected) ambient.set("signal", 0.55, "voice live"); if (status?.connected) ambient.set("signal", 0.55, "voice live");
else ambient.set("vermilion", 0.35, "voice idle"); else ambient.set("vermilion", 0.35, "voice idle");
@@ -71,13 +118,9 @@ export function VoiceView({
if (error && !status) return <ErrorState error={error} />; if (error && !status) return <ErrorState error={error} />;
if (!status && isLoading) if (!status && isLoading)
return ( return (
<div className="space-y-5"> <p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
<SkeletonPanel rows={2} /> menyetel ulang stage
<div className="grid gap-5 lg:grid-cols-3"> </p>
<SkeletonPanel className="lg:col-span-2" rows={4} />
<SkeletonPanel rows={4} />
</div>
</div>
); );
const connected = status?.connected ?? false; const connected = status?.connected ?? false;
@@ -118,43 +161,94 @@ export function VoiceView({
}; };
return ( return (
<div className="space-y-5"> <div className="min-h-full">
<GlassPanel> {/* Stage presence — top-center whisper */}
<div className="flex flex-wrap items-center justify-between gap-3"> <section
<GuildChannelPicker className="pointer-events-none absolute inset-x-0 top-16 hidden justify-center md:flex"
mode="voice" aria-label="Live speakers"
guildsInitial={initialGuilds} >
guildId={guildId} <div className="pointer-events-auto flex max-w-[60vw] flex-wrap justify-center gap-2">
channelId={channelId} {speakers.length === 0 ? (
onChange={(g, c) => { <EmptyState
setGuildId(g); icon={<MicOff className="size-6" />}
setChannelId(c); title={connected ? "Silent right now" : "Not connected"}
}} description={
/> connected
<div className="flex items-center gap-2"> ? "Speakers appear as they talk."
{connected ? ( : "Connect to a voice channel to see presence."
<Button }
variant="danger" />
size="sm" ) : (
onClick={() => disconnect.mutate()} <VoiceStage speakers={speakers} />
disabled={disconnect.isPending} )}
>
<PhoneOff className="size-4" /> Disconnect
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={onConnect}
disabled={connect.isPending}
>
<Radio className="size-4" /> Connect
</Button>
)}
</div>
</div> </div>
</section>
<div className="mt-3 flex flex-wrap items-center gap-2"> {/* Connections — right */}
<section
className="pointer-events-auto absolute right-5 top-16 hidden w-64 md:block"
aria-label="Connections"
>
<p className="eyebrow mb-2">links · {speakers.length} present</p>
<div className="space-y-1.5">
{(status?.connections ?? []).map((c) => (
<div
key={`${c.guildId}-${c.channelId}`}
className="flex items-center gap-2 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/55 px-3 py-1.5 font-mono text-xs text-[var(--color-ink-soft)] backdrop-blur-md"
>
<span className="size-2 rounded-full bg-signal" />
<span className="flex-1 truncate">{c.channelName}</span>
<span className="text-[var(--color-ink-faint)]">
{new Date(c.connectedAt).toLocaleTimeString()}
</span>
</div>
))}
{(status?.connections ?? []).length === 0 ? (
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
no active links
</p>
) : null}
<p className="pt-1 font-mono text-xs text-[var(--color-ink-faint)]">
channel: {status?.activeChannelName ?? "—"}
</p>
</div>
</section>
{/* Console — bottom-left */}
<section
className="pointer-events-auto absolute bottom-20 left-5 z-20 w-[min(24rem,92vw)] space-y-2.5 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl lg:bottom-24"
aria-label="Voice console"
>
<GuildChannelPicker
mode="voice"
guildsInitial={initialGuilds}
guildId={guildId}
channelId={channelId}
onChange={(g, c) => {
setGuildId(g);
setChannelId(c);
}}
/>
<div className="flex items-center gap-2">
{connected ? (
<Button
variant="danger"
size="sm"
onClick={() => disconnect.mutate()}
disabled={disconnect.isPending}
>
<PhoneOff className="size-4" /> Disconnect
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={onConnect}
disabled={connect.isPending}
>
<Radio className="size-4" /> Connect
</Button>
)}
<Button <Button
variant={micOn ? "primary" : "outline"} variant={micOn ? "primary" : "outline"}
size="sm" size="sm"
@@ -165,16 +259,6 @@ export function VoiceView({
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />} {micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
{micOn ? "Mic live" : "Push-to-talk"} {micOn ? "Mic live" : "Push-to-talk"}
</Button> </Button>
{micOn && (
<div
className="flex items-center gap-2 rounded-[10px] border border-signal/30 bg-signal/[0.06] px-3 py-1.5"
role="status"
aria-label="Microphone level meter"
>
<Mic className="size-4 text-signal" />
<Equalizer bars={micBars} className="w-28" />
</div>
)}
<Button <Button
variant={listen.active ? "primary" : "outline"} variant={listen.active ? "primary" : "outline"}
size="sm" size="sm"
@@ -187,125 +271,62 @@ export function VoiceView({
)} )}
{listen.active ? "Listening" : "Listen in"} {listen.active ? "Listening" : "Listen in"}
</Button> </Button>
{listen.active && (
<div className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-1.5">
<Waves className="size-4 text-signal" />
<Equalizer bars={listenBars} className="w-40" />
</div>
)}
<div className="ml-auto flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-ink-faint">
<MicOff className="size-3.5" />
<input
type="range"
min={0}
max={100}
value={micVol}
onChange={(e) => {
const v = Number(e.target.value);
setMicVol(v);
mic.setVolume(v);
}}
aria-label="Mic transmit volume"
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
/>
<span className="mono w-8 text-right">{micVol}%</span>
</label>
<label className="flex items-center gap-2 text-xs text-ink-faint">
<Volume2 className="size-3.5" />
<input
type="range"
min={0}
max={100}
value={listenVol}
onChange={(e) => {
const v = Number(e.target.value);
setListenVol(v);
listen.setVolume(v);
}}
aria-label="Listen volume"
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
/>
<span className="mono w-8 text-right">{listenVol}%</span>
</label>
</div>
</div> </div>
</GlassPanel>
<div className="grid gap-5 lg:grid-cols-3"> {micOn ? (
<GlassPanel className="lg:col-span-2"> <div
<SectionHeader className="flex items-center gap-2 px-1"
eyebrow="stage" role="status"
title="Live speakers" aria-label="Microphone level meter"
action={ >
<span className="mono text-xs text-ink-faint"> <Mic className="size-3.5 text-signal" />
{speakers.length} present <Equalizer bars={micBars} className="w-28" />
</span> </div>
} ) : null}
/> {listen.active ? (
{speakers.length === 0 ? ( <div className="flex items-center gap-2 px-1">
<EmptyState <Waves className="size-3.5 text-signal" />
icon={<MicOff className="size-7" />} <Equalizer bars={listenBars} className="w-40" />
title={connected ? "Silent right now" : "Not connected"} </div>
description={ ) : null}
connected
? "Speakers appear as they talk." <div className="flex items-center gap-4 pt-1">
: "Connect to a voice channel to see presence." <label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
} <MicOff className="size-3.5" />
<input
type="range"
min={0}
max={100}
value={micVol}
onChange={(e) => {
const v = Number(e.target.value);
setMicVol(v);
mic.setVolume(v);
}}
aria-label="Mic transmit volume"
className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
/> />
) : ( <span className="w-8 text-right">{micVol}%</span>
<> </label>
<VoiceStage speakers={speakers} /> <label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
<div className="mt-2 flex flex-wrap justify-center gap-2"> <Volume2 className="size-3.5" />
{speakers.map((sp) => ( <input
<span type="range"
key={sp.userId} min={0}
className={`mono flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${ max={100}
sp.speaking value={listenVol}
? "border-signal/40 bg-signal/10 text-signal" onChange={(e) => {
: "border-hairline bg-white/5 text-ink-soft" const v = Number(e.target.value);
}`} setListenVol(v);
> listen.setVolume(v);
<span }}
className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`} aria-label="Listen volume"
/> className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
{sp.username} />
</span> <span className="w-8 text-right">{listenVol}%</span>
))} </label>
</div> </div>
</> </section>
)}
</GlassPanel>
<GlassPanel>
<SectionHeader eyebrow="links" title="Connections" />
<div className="space-y-2">
{(status?.connections ?? []).map((c) => (
<div
key={`${c.guildId}-${c.channelId}`}
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm"
>
<span className="size-2 rounded-full bg-signal" />
<span className="flex-1 truncate text-ink-soft">
{c.channelName}
</span>
<span className="mono text-[0.6rem] text-ink-faint">
{new Date(c.connectedAt).toLocaleTimeString()}
</span>
</div>
))}
{(status?.connections ?? []).length === 0 && (
<div className="py-6 text-center text-xs text-ink-faint">
No active links
</div>
)}
</div>
<div className="mt-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft">
<span className="mono text-ink-faint">channel</span>{" "}
{status?.activeChannelName ?? "—"}
</div>
</GlassPanel>
</div>
</div> </div>
); );
} }