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 && ( -
- - -
- )}
- -
- - - {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}% + + +
+
); }