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";
/**
* 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<string | null>(
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 <ErrorState error={error} />;
if (!status && isLoading)
return (
<div className="space-y-5">
<SkeletonPanel rows={2} />
<div className="grid gap-5 lg:grid-cols-3">
<SkeletonPanel className="lg:col-span-2" rows={4} />
<SkeletonPanel rows={4} />
</div>
</div>
<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)]">
menyetel ulang stage
</p>
);
const connected = status?.connected ?? false;
@@ -118,43 +161,94 @@ export function VoiceView({
};
return (
<div className="space-y-5">
<GlassPanel>
<div className="flex flex-wrap items-center justify-between gap-3">
<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>
)}
</div>
<div className="min-h-full">
{/* Stage presence — top-center whisper */}
<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>
<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
variant={micOn ? "primary" : "outline"}
size="sm"
@@ -165,16 +259,6 @@ export function VoiceView({
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
{micOn ? "Mic live" : "Push-to-talk"}
</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
variant={listen.active ? "primary" : "outline"}
size="sm"
@@ -187,125 +271,62 @@ export function VoiceView({
)}
{listen.active ? "Listening" : "Listen in"}
</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>
</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."
}
{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" />
</div>
) : null}
<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" />
<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)]"
/>
) : (
<>
<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>
<span className="w-8 text-right">{micVol}%</span>
</label>
<label className="flex items-center gap-2 font-mono text-xs text-[var(--color-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-20 cursor-pointer accent-[var(--color-signal)]"
/>
<span className="w-8 text-right">{listenVol}%</span>
</label>
</div>
</section>
</div>
);
}