diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx
index 35598492..1972387a 100644
--- a/services/frontend/src/app/(dashboard)/media/view.tsx
+++ b/services/frontend/src/app/(dashboard)/media/view.tsx
@@ -1,5 +1,10 @@
"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 {
ListMusic,
Play,
@@ -9,15 +14,14 @@ import {
Square,
Volume2,
} from "lucide-react";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
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 {
- ErrorState,
- SectionHeader,
- SkeletonHero,
- SkeletonPanel,
-} from "@/components/shared";
+ useSceneFocusSetter,
+ useScenePublish,
+} from "@/components/shell/scene-graph-context";
import {
useMediaLoop,
useMediaQueue,
@@ -26,11 +30,37 @@ import {
useMediaStop,
useMediaWsSync,
} from "@/hooks";
+import type { ConstellationGraph } from "@/lib/constellation/graph";
import { formatDuration } from "@/lib/format";
import type { MediaState } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
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 }) {
const ws = useWebSocket();
const { data: media, isLoading, error } = useMediaState(initialStatus);
@@ -40,6 +70,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const loop = useMediaLoop();
useMediaWsSync(ws);
const ambient = useAmbient();
+ const publish = useScenePublish();
+ const setFocus = useSceneFocusSetter();
const [url, setUrl] = useState("");
@@ -51,6 +83,19 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
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";
useEffect(() => {
ambient.set(
@@ -80,126 +125,105 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
};
if (error && !media) return ;
- if (!media && isLoading)
- return (
-
-
-
-
- );
+ if (!media && isLoading) return ;
return (
-
-
-
-
+
+ {/* Now playing — top-center whisper */}
+
+
-
- {current?.thumbnailUrl ? (
- // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
-

- ) : (
-
- )}
-
+ {current?.thumbnailUrl ? (
+ // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
+

+ ) : (
+
+ )}
-
-
-
+
+
{playing ? (
- <>
-
- {[0, 1, 2].map((i) => (
-
- ))}
-
- Now playing
- >
- ) : current ? (
- "Paused"
- ) : (
- "Nothing queued"
- )}
-
-
- {current?.title ?? "Nothing queued"}
-
-
- {current?.source && (
- {current.source}
- )}
- {current?.mode && (
- {current.mode}
- )}
- {formatDuration(current?.durationMs) && (
-
- {formatDuration(current?.durationMs)}
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
- )}
-
-
-
-
-
-
-
+ ) : null}
+ {playing ? "Now playing" : current ? "Paused" : "Nothing queued"}
+
+
+ {current?.title ?? "Nothing queued"}
+ {formatDuration(current?.durationMs)
+ ? ` · ${formatDuration(current?.durationMs)}`
+ : ""}
+
+
-
-
- setUrl(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && onPlay()}
- />
-
-
-
-
+ {/* Console — bottom-left */}
+
+
+ setUrl(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && onPlay()}
+ />
+
+
+
+
+
+
+
+
+
-
+
{Math.round((media?.musicVolume ?? 0) * 100)}%
-
+
-
-
- {queueList.length} track{queueList.length === 1 ? "" : "s"}
- {queueTotal && ` · ${formatDuration(queueTotal)}`}
-
- }
- />
- {queueList.length === 0 ? (
-
-
-
Queue is empty
-
- Paste a URL above to start playback.
+ {/* Up next — right dock */}
+
+
+ up next
+
+ {queueList.length} track{queueList.length === 1 ? "" : "s"}
+ {queueTotal ? ` · ${formatDuration(queueTotal)}` : ""}
+
+
+
+ {queueList.length === 0 ? (
+
+
+
+ Queue is empty
+
+
+ Paste a URL to start playback.
+
-
- ) : (
-
- {queueList.map((item, i) => {
- const isNext = i === 0 && playing;
- return (
-
-
{i + 1}
- {item.thumbnailUrl ? (
- // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
-

- ) : (
-
-
+ ) : (
+
+ {queueList.map((item, i) => {
+ const isNext = i === 0 && playing;
+ return (
+
+
+ {i + 1}
- )}
-
-
- {item.title}
+ {item.thumbnailUrl ? (
+ // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
+

+ ) : (
+
+
+
+ )}
+
+
+ {item.title}
+
+
+ {item.source}
+
-
- {item.source}
-
-
- {isNext && (
-
- up next
-
- )}
-
- {item.mode ?? "music"}
-
- {formatDuration(item.durationMs) && (
-
+ {isNext ? (
+
+ up next
+
+ ) : null}
+
{formatDuration(item.durationMs)}
- )}
-
- );
- })}
-
- )}
-
+
+ );
+ })}
+
+ )}
+
+
);
}
diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx
index 4268b809..839a9602 100644
--- a/services/frontend/src/app/(dashboard)/recordings/view.tsx
+++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx
@@ -1,18 +1,25 @@
"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 { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import {
Avatar,
Badge,
Button,
- GlassCard,
- GlassPanel,
Skeleton,
toast,
} 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 {
NowPlayingChip,
RecordingAudioPlayer,
@@ -22,11 +29,29 @@ import {
useRecordings,
useRecordingsWsSync,
} from "@/hooks";
+import type { ConstellationGraph } from "@/lib/constellation/graph";
import { formatBytes, formatRelativeTime } from "@/lib/format";
import type { VoiceRecording } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
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({
initialItems,
}: {
@@ -37,8 +62,25 @@ export function RecordingsView({
const del = useDeleteRecording();
useRecordingsWsSync(ws);
const ambient = useAmbient();
+ const publish = useScenePublish();
+ const setFocus = useSceneFocusSetter();
const [playingId, setPlayingId] = useState
(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(() => {
ambient.set("signal", 0.3, "recordings");
}, [ambient]);
@@ -59,14 +101,17 @@ export function RecordingsView({
if (error && !items) return ;
if (!items && isLoading)
return (
-
+
-
- {Array.from({ length: 6 }).map((_, i) => (
-
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
@@ -75,118 +120,168 @@ export function RecordingsView({
-
+
))}
-
+
);
return (
-
-
- {(items ?? []).length} clips
+
+ {/* Archive dock — right */}
+
+
+
voice captures
+
+ {list.length} clips
- }
- />
- {(items ?? []).length === 0 ? (
-
}
- title="No recordings"
- description="Voice clips captured by the bot appear here."
- />
- ) : (
-
- {(items ?? []).map((r, i) => {
- const up = uploadStatus(r);
- const isPlaying = playingId === r.id;
- return (
-
-
-
-
-
- {r.username}
-
-
-
-
- {r.channel_name ?? "voice"}
-
- ·
-
- {formatRelativeTime(r.created_at)}
-
-
-
- {isPlaying &&
}
- {up && !isPlaying &&
{up.label}}
-
- {formatBytes(r.size_bytes)}
-
-
-
- {r.download_url ? (
-
- setPlayingId((prev) => {
- if (active) return r.id;
- // Only clear if THIS card was the one playing.
- return prev === r.id ? null : prev;
- })
- }
- />
- ) : (
-
-
- {r.upload_status === "pending"
- ? "Upload pending…"
- : r.upload_error
- ? r.upload_error
- : "Processing…"}
-
- )}
-
-
- {r.download_url && (
-
- Download
-
- )}
-
-
-
- );
- })}
- )}
-
+
+ {list.length === 0 ? (
+
}
+ title="No recordings"
+ description="Voice clips captured by the bot appear here."
+ />
+ ) : (
+
+ {list.map((r, i) => {
+ const up = uploadStatus(r);
+ const isPlaying = playingId === r.id;
+ return (
+
+
+
+
+
+ {r.username}
+
+
+
+
+ {r.channel_name ?? "voice"}
+
+ ·
+ {formatRelativeTime(r.created_at)}
+
+
+ {isPlaying ?
: null}
+ {up && !isPlaying ? (
+
{up.label}
+ ) : null}
+
+ {formatBytes(r.size_bytes)}
+
+
+
+ {r.download_url ? (
+
+ setPlayingId((prev) => {
+ if (active) return r.id;
+ return prev === r.id ? null : prev;
+ })
+ }
+ />
+ ) : (
+
+
+ {r.upload_status === "pending"
+ ? "Upload pending…"
+ : r.upload_error
+ ? r.upload_error
+ : "Processing…"}
+
+ )}
+
+
+ {r.download_url ? (
+
+ Download
+
+ ) : null}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* Mobile dock */}
+
+ {list.length === 0 ? (
+
+ ) : (
+ list.map((r) => (
+
+
+ {r.username} · {formatRelativeTime(r.created_at)}
+
+ {r.download_url ? (
+
+ setPlayingId((prev) =>
+ active ? r.id : prev === r.id ? null : prev,
+ )
+ }
+ />
+ ) : (
+
+ processing…
+
+ )}
+
+ ))
+ )}
+
+
+ {/* Hint */}
+
+ setiap klip adalah simpel di cincin timeline — yang sedang play menyala
+ di langit
+
+
);
}
diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx
index 789e59e0..efa99c69 100644
--- a/services/frontend/src/app/(dashboard)/voice/view.tsx
+++ b/services/frontend/src/app/(dashboard)/voice/view.tsx
@@ -1,5 +1,10 @@
"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 {
Headphones,
Mic,
@@ -9,17 +14,16 @@ import {
Volume2,
Waves,
} from "lucide-react";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Equalizer } from "@/components/charts";
-import { Button, GlassPanel, toast } from "@/components/primitives";
-import {
- EmptyState,
- ErrorState,
- SectionHeader,
- SkeletonPanel,
-} from "@/components/shared";
+import { Button, toast } from "@/components/primitives";
+import { EmptyState, ErrorState } from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
+import {
+ useSceneFocusSetter,
+ useScenePublish,
+} from "@/components/shell/scene-graph-context";
import { VoiceStage } from "@/components/voice/voice-stage";
import {
useMicTransmit,
@@ -29,9 +33,37 @@ import {
useVoiceListen,
useVoiceStatus,
} from "@/hooks";
+import type { ConstellationGraph } from "@/lib/constellation/graph";
import type { Guild, VoiceStatus } from "@/lib/types";
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({
initialStatus,
initialGuilds,
@@ -47,6 +79,8 @@ export function VoiceView({
const listen = useVoiceListen(ws);
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
const ambient = useAmbient();
+ const publish = useScenePublish();
+ const setFocus = useSceneFocusSetter();
const [guildId, setGuildId] = useState(
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
@@ -63,6 +97,19 @@ export function VoiceView({
return unsub;
}, [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(() => {
if (status?.connected) ambient.set("signal", 0.55, "voice live");
else ambient.set("vermilion", 0.35, "voice idle");
@@ -71,13 +118,9 @@ export function VoiceView({
if (error && !status) return ;
if (!status && isLoading)
return (
-
+
+ menyetel ulang stage…
+
);
const connected = status?.connected ?? false;
@@ -118,43 +161,94 @@ export function VoiceView({
};
return (
-
-
-
-
{
- setGuildId(g);
- setChannelId(c);
- }}
- />
-
- {connected ? (
-
- ) : (
-
- )}
-
+
+ {/* Stage presence — top-center whisper */}
+
+
+ {speakers.length === 0 ? (
+ }
+ title={connected ? "Silent right now" : "Not connected"}
+ description={
+ connected
+ ? "Speakers appear as they talk."
+ : "Connect to a voice channel to see presence."
+ }
+ />
+ ) : (
+
+ )}
+
-
+ {/* Connections — right */}
+
+ links · {speakers.length} present
+
+ {(status?.connections ?? []).map((c) => (
+
+
+ {c.channelName}
+
+ {new Date(c.connectedAt).toLocaleTimeString()}
+
+
+ ))}
+ {(status?.connections ?? []).length === 0 ? (
+
+ no active links
+
+ ) : null}
+
+ channel: {status?.activeChannelName ?? "—"}
+
+
+
+
+ {/* Console — bottom-left */}
+
+ {
+ setGuildId(g);
+ setChannelId(c);
+ }}
+ />
+
+ {connected ? (
+
+ ) : (
+
+ )}
:
}
{micOn ? "Mic live" : "Push-to-talk"}
- {micOn && (
-
-
-
-
- )}
-
-
-
-
- {speakers.length} present
-
- }
- />
- {speakers.length === 0 ? (
- }
- title={connected ? "Silent right now" : "Not connected"}
- description={
- connected
- ? "Speakers appear as they talk."
- : "Connect to a voice channel to see presence."
- }
+ {micOn ? (
+
+
+
+
+ ) : null}
+ {listen.active ? (
+
+
+
+
+ ) : null}
+
+
+
+ {micVol}%
+
+
+
+
);
}