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,23 +125,19 @@ 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
@@ -106,59 +147,55 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<ListMusic className="size-10 text-signal" /> <ListMusic className="size-4 text-signal" />
)} )}
</div> </div>
</div> <div className="min-w-0">
<p className="eyebrow flex items-center gap-2">
<div className="min-w-0 flex-1">
<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={{ style={{ animationDelay: `${i * 160}ms`, height: "100%" }}
animationDelay: `${i * 160}ms`,
height: "100%",
}}
/> />
))} ))}
</span> </span>
<span className="text-signal">Now playing</span> ) : null}
</> {playing ? "Now playing" : current ? "Paused" : "Nothing queued"}
) : current ? ( </p>
"Paused" <p className="truncate text-sm text-[var(--color-ink)]">
) : (
"Nothing queued"
)}
</div>
<h2 className="display text-balance text-2xl text-ink">
{current?.title ?? "Nothing queued"} {current?.title ?? "Nothing queued"}
</h2> {formatDuration(current?.durationMs)
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint"> ? ` · ${formatDuration(current?.durationMs)}`
{current?.source && ( : ""}
<span className="mono truncate">{current.source}</span> </p>
)}
{current?.mode && (
<span className="pill capitalize">{current.mode}</span>
)}
{formatDuration(current?.durationMs) && (
<span className="mono">
{formatDuration(current?.durationMs)}
</span>
)}
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-2"> </div>
</section>
{/* Console — bottom-left */}
<section
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"
aria-label="Player console"
>
<div className="relative">
<Input
placeholder="Paste a YouTube / music URL…"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onPlay()}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={onPlay} onClick={onPlay}
disabled={queue.isPending} disabled={queue.isPending}
> >
<Play className="size-4" /> Queue & play <Play className="size-4" /> Queue &amp; play
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -184,22 +221,9 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
> >
<Repeat className="size-4" /> Loop <Repeat className="size-4" /> Loop
</Button> </Button>
</div> <div className="ml-auto flex items-center gap-2">
</div> <Volume2 className="size-4 text-[var(--color-ink-faint)]" />
</div> <div className="h-1.5 w-20 overflow-hidden rounded-full bg-white/10">
<div className="mt-5 flex flex-wrap items-center gap-3">
<div className="relative min-w-0 flex-1">
<Input
placeholder="Paste a YouTube / music URL…"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onPlay()}
/>
</div>
<div className="flex shrink-0 items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
<Volume2 className="size-4 text-ink-faint" />
<div className="h-1.5 w-24 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,47 +231,53 @@ 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">
<span className="eyebrow">up next</span>
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
{queueList.length} track{queueList.length === 1 ? "" : "s"} {queueList.length} track{queueList.length === 1 ? "" : "s"}
{queueTotal && ` · ${formatDuration(queueTotal)}`} {queueTotal ? ` · ${formatDuration(queueTotal)}` : ""}
</span> </span>
} </div>
/> <div className="min-h-0 flex-1 overflow-y-auto p-2">
{queueList.length === 0 ? ( {queueList.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-center"> <div className="flex flex-col items-center gap-2 py-10 text-center">
<Radio className="size-6 text-ink-faint" /> <Radio className="size-6 text-[var(--color-ink-faint)]" />
<div className="text-sm text-ink-soft">Queue is empty</div> <p className="text-sm text-[var(--color-ink-soft)]">
<div className="text-xs text-ink-faint"> Queue is empty
Paste a URL above to start playback. </p>
</div> <p className="font-mono text-xs text-[var(--color-ink-faint)]">
Paste a URL to start playback.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-1.5">
{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-[10px] border px-3 py-2.5 ${ className={`animate-stagger flex items-center gap-3 rounded-xl border px-3 py-2 ${
isNext isNext
? "border-signal/40 bg-signal/[0.07]" ? "border-signal/40 bg-signal/[0.07]"
: "border-hairline bg-white/5" : "border-[var(--color-hairline)]"
}`} }`}
style={staggerDelay(i)} style={staggerDelay(i)}
> >
<span className="mono w-5 text-ink-faint">{i + 1}</span> <span className="w-5 font-mono text-xs text-[var(--color-ink-faint)]">
{i + 1}
</span>
{item.thumbnailUrl ? ( {item.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
@@ -257,37 +287,33 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5"> <span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-[var(--color-hairline)]">
<ListMusic className="size-4 text-ink-faint" /> <ListMusic className="size-4 text-[var(--color-ink-faint)]" />
</span> </span>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink"> <p className="truncate text-sm text-[var(--color-ink)]">
{item.title} {item.title}
</div> </p>
<div className="mono truncate text-[0.65rem] text-ink-faint"> <p className="truncate font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
{item.source} {item.source}
</p>
</div> </div>
</div> {isNext ? (
{isNext && ( <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">
<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">
up next up next
</span> </span>
)} ) : null}
<span className="pill hidden capitalize sm:inline-flex"> <span className="w-10 text-right font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
{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>
)} )}
</GlassPanel> </div>
</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,64 +120,67 @@ 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>
} </div>
/> <div className="min-h-0 flex-1 overflow-y-auto p-2.5">
{(items ?? []).length === 0 ? ( {list.length === 0 ? (
<EmptyState <EmptyState
icon={<Headphones className="size-7" />} icon={<Headphones className="size-6" />}
title="No recordings" title="No recordings"
description="Voice clips captured by the bot appear here." description="Voice clips captured by the bot appear here."
/> />
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="space-y-2">
{(items ?? []).map((r, i) => { {list.map((r, i) => {
const up = uploadStatus(r); const up = uploadStatus(r);
const isPlaying = playingId === r.id; const isPlaying = playingId === r.id;
return ( return (
<GlassCard <article
key={r.id} key={r.id}
className={`animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06] ${ className={`animate-stagger flex flex-col gap-2.5 rounded-xl border p-3 transition-colors ${
isPlaying isPlaying
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]" ? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
: "" : "border-[var(--color-hairline)] hover:bg-white/[0.04]"
}`} }`}
style={staggerDelay(i)} style={staggerDelay(i)}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar src={r.avatar_url} name={r.username} size={38} /> <Avatar src={r.avatar_url} name={r.username} size={34} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-ink"> <p className="truncate text-sm font-semibold text-[var(--color-ink)]">
{r.username} {r.username}
</div> </p>
<div className="flex items-center gap-1.5 text-[0.65rem] text-ink-faint"> <p className="flex items-center gap-1.5 font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
<Hash className="size-3" /> <Hash className="size-3" />
<span className="truncate"> <span className="truncate">
{r.channel_name ?? "voice"} {r.channel_name ?? "voice"}
</span> </span>
<span className="text-ink-faint/60">·</span> <span>·</span>
<span className="mono"> <span>{formatRelativeTime(r.created_at)}</span>
{formatRelativeTime(r.created_at)} </p>
</span>
</div> </div>
</div> {isPlaying ? <NowPlayingChip /> : null}
{isPlaying && <NowPlayingChip />} {up && !isPlaying ? (
{up && !isPlaying && <Badge tone={up.tone}>{up.label}</Badge>} <Badge tone={up.tone}>{up.label}</Badge>
<span className="mono text-[0.65rem] text-ink-faint"> ) : null}
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
{formatBytes(r.size_bytes)} {formatBytes(r.size_bytes)}
</span> </span>
</div> </div>
@@ -144,33 +192,32 @@ export function RecordingsView({
onPlayStateChange={(active) => onPlayStateChange={(active) =>
setPlayingId((prev) => { setPlayingId((prev) => {
if (active) return r.id; if (active) return r.id;
// Only clear if THIS card was the one playing.
return prev === r.id ? null : prev; 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"> <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" /> <Loader2 className="size-3.5 animate-spin" />
{r.upload_status === "pending" {r.upload_status === "pending"
? "Upload pending…" ? "Upload pending…"
: r.upload_error : r.upload_error
? r.upload_error ? r.upload_error
: "Processing…"} : "Processing…"}
</div> </p>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{r.download_url && ( {r.download_url ? (
<a <a
href={r.download_url} href={r.download_url}
target="_blank" target="_blank"
rel="noreferrer" 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" 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 <Download className="size-3.5" /> Download
</a> </a>
)} ) : null}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -181,12 +228,60 @@ export function RecordingsView({
<Trash2 className="size-3.5" /> Delete <Trash2 className="size-3.5" /> Delete
</Button> </Button>
</div> </div>
</GlassCard> </article>
); );
})} })}
</div> </div>
)} )}
</GlassPanel> </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,9 +161,64 @@ 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
className="pointer-events-none absolute inset-x-0 top-16 hidden justify-center md:flex"
aria-label="Live speakers"
>
<div className="pointer-events-auto flex max-w-[60vw] flex-wrap justify-center gap-2">
{speakers.length === 0 ? (
<EmptyState
icon={<MicOff className="size-6" />}
title={connected ? "Silent right now" : "Not connected"}
description={
connected
? "Speakers appear as they talk."
: "Connect to a voice channel to see presence."
}
/>
) : (
<VoiceStage speakers={speakers} />
)}
</div>
</section>
{/* 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 <GuildChannelPicker
mode="voice" mode="voice"
guildsInitial={initialGuilds} guildsInitial={initialGuilds}
@@ -151,10 +249,6 @@ export function VoiceView({
<Radio className="size-4" /> Connect <Radio className="size-4" /> Connect
</Button> </Button>
)} )}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<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,14 +271,27 @@ export function VoiceView({
)} )}
{listen.active ? "Listening" : "Listen in"} {listen.active ? "Listening" : "Listen in"}
</Button> </Button>
{listen.active && ( </div>
<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" /> {micOn ? (
<div
className="flex items-center gap-2 px-1"
role="status"
aria-label="Microphone level meter"
>
<Mic className="size-3.5 text-signal" />
<Equalizer bars={micBars} className="w-28" />
</div>
) : null}
{listen.active ? (
<div className="flex items-center gap-2 px-1">
<Waves className="size-3.5 text-signal" />
<Equalizer bars={listenBars} className="w-40" /> <Equalizer bars={listenBars} className="w-40" />
</div> </div>
)} ) : null}
<div className="ml-auto flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-ink-faint"> <div className="flex items-center gap-4 pt-1">
<label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
<MicOff className="size-3.5" /> <MicOff className="size-3.5" />
<input <input
type="range" type="range"
@@ -207,11 +304,11 @@ export function VoiceView({
mic.setVolume(v); mic.setVolume(v);
}} }}
aria-label="Mic transmit volume" aria-label="Mic transmit volume"
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]" className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
/> />
<span className="mono w-8 text-right">{micVol}%</span> <span className="w-8 text-right">{micVol}%</span>
</label> </label>
<label className="flex items-center gap-2 text-xs text-ink-faint"> <label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
<Volume2 className="size-3.5" /> <Volume2 className="size-3.5" />
<input <input
type="range" type="range"
@@ -224,88 +321,12 @@ export function VoiceView({
listen.setVolume(v); listen.setVolume(v);
}} }}
aria-label="Listen volume" aria-label="Listen volume"
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]" className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
/> />
<span className="mono w-8 text-right">{listenVol}%</span> <span className="w-8 text-right">{listenVol}%</span>
</label> </label>
</div> </div>
</div> </section>
</GlassPanel>
<div className="grid gap-5 lg:grid-cols-3">
<GlassPanel className="lg:col-span-2">
<SectionHeader
eyebrow="stage"
title="Live speakers"
action={
<span className="mono text-xs text-ink-faint">
{speakers.length} present
</span>
}
/>
{speakers.length === 0 ? (
<EmptyState
icon={<MicOff className="size-7" />}
title={connected ? "Silent right now" : "Not connected"}
description={
connected
? "Speakers appear as they talk."
: "Connect to a voice channel to see presence."
}
/>
) : (
<>
<VoiceStage speakers={speakers} />
<div className="mt-2 flex flex-wrap justify-center gap-2">
{speakers.map((sp) => (
<span
key={sp.userId}
className={`mono flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${
sp.speaking
? "border-signal/40 bg-signal/10 text-signal"
: "border-hairline bg-white/5 text-ink-soft"
}`}
>
<span
className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`}
/>
{sp.username}
</span>
))}
</div>
</>
)}
</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>
); );
} }