2026-08-07 10:44:03 +07:00
|
|
|
"use client";
|
|
|
|
|
|
2026-08-24 15:44:10 +07:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-08-07 10:44:03 +07:00
|
|
|
import {
|
2026-08-15 20:03:55 +07:00
|
|
|
Headphones,
|
|
|
|
|
Mic,
|
|
|
|
|
MicOff,
|
|
|
|
|
PhoneOff,
|
|
|
|
|
Radio,
|
|
|
|
|
Volume2,
|
|
|
|
|
Waves,
|
|
|
|
|
} from "lucide-react";
|
2026-08-24 15:44:10 +07:00
|
|
|
import { useEffect, useMemo, useState } from "react";
|
2026-08-15 20:03:55 +07:00
|
|
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
|
|
|
|
import { Equalizer } from "@/components/charts";
|
2026-08-24 15:44:10 +07:00
|
|
|
import { Button, toast } from "@/components/primitives";
|
|
|
|
|
import { EmptyState, ErrorState } from "@/components/shared";
|
2026-08-15 20:03:55 +07:00
|
|
|
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
2026-08-24 15:44:10 +07:00
|
|
|
import {
|
|
|
|
|
useSceneFocusSetter,
|
|
|
|
|
useScenePublish,
|
|
|
|
|
} from "@/components/shell/scene-graph-context";
|
2026-08-15 20:03:55 +07:00
|
|
|
import { VoiceStage } from "@/components/voice/voice-stage";
|
|
|
|
|
import {
|
|
|
|
|
useMicTransmit,
|
|
|
|
|
useSpeakers,
|
2026-08-07 10:44:03 +07:00
|
|
|
useVoiceConnect,
|
|
|
|
|
useVoiceDisconnect,
|
|
|
|
|
useVoiceListen,
|
2026-08-15 20:03:55 +07:00
|
|
|
useVoiceStatus,
|
2026-08-07 10:44:03 +07:00
|
|
|
} from "@/hooks";
|
2026-08-24 15:44:10 +07:00
|
|
|
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
2026-08-15 17:53:48 +07:00
|
|
|
import type { Guild, VoiceStatus } from "@/lib/types";
|
2026-08-15 20:03:55 +07:00
|
|
|
import { useWebSocket } from "@/lib/ws/context";
|
2026-08-07 10:44:03 +07:00
|
|
|
|
2026-08-24 15:44:10 +07:00
|
|
|
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}`,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
export function VoiceView({
|
|
|
|
|
initialStatus,
|
|
|
|
|
initialGuilds,
|
|
|
|
|
}: {
|
|
|
|
|
initialStatus?: VoiceStatus;
|
|
|
|
|
initialGuilds?: Guild[];
|
|
|
|
|
}) {
|
2026-08-07 10:44:03 +07:00
|
|
|
const ws = useWebSocket();
|
2026-08-15 17:53:48 +07:00
|
|
|
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
|
2026-08-14 10:49:44 +07:00
|
|
|
const connect = useVoiceConnect();
|
|
|
|
|
const disconnect = useVoiceDisconnect();
|
2026-08-14 12:35:44 +07:00
|
|
|
const mic = useMicTransmit(ws);
|
2026-08-15 17:53:48 +07:00
|
|
|
const listen = useVoiceListen(ws);
|
|
|
|
|
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
|
|
|
|
const ambient = useAmbient();
|
2026-08-24 15:44:10 +07:00
|
|
|
const publish = useScenePublish();
|
|
|
|
|
const setFocus = useSceneFocusSetter();
|
2026-08-15 17:53:48 +07:00
|
|
|
|
|
|
|
|
const [guildId, setGuildId] = useState<string | null>(
|
|
|
|
|
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
|
|
|
|
|
);
|
|
|
|
|
const [channelId, setChannelId] = useState<string | null>(
|
|
|
|
|
initialStatus?.activeChannelId ?? null,
|
|
|
|
|
);
|
|
|
|
|
const [micOn, setMicOn] = useState(false);
|
2026-08-22 12:53:18 +07:00
|
|
|
const [micVol, setMicVol] = useState(100);
|
|
|
|
|
const [listenVol, setListenVol] = useState(75);
|
2026-08-07 10:44:03 +07:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const unsub = subscribe(ws);
|
2026-08-14 10:49:44 +07:00
|
|
|
return unsub;
|
|
|
|
|
}, [subscribe, ws]);
|
2026-08-07 10:44:03 +07:00
|
|
|
|
2026-08-24 15:44:10 +07:00
|
|
|
const graph = useMemo(() => speakerGraph(speakers), [speakers]);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
publish({ graph, focus: null });
|
|
|
|
|
}, [graph, publish]);
|
|
|
|
|
|
|
|
|
|
useEffect(
|
|
|
|
|
() => () => {
|
|
|
|
|
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
|
|
|
setFocus(null);
|
|
|
|
|
},
|
|
|
|
|
[publish, setFocus],
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
|
|
|
|
else ambient.set("vermilion", 0.35, "voice idle");
|
|
|
|
|
}, [status?.connected, ambient]);
|
2026-08-14 11:28:08 +07:00
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
if (error && !status) return <ErrorState error={error} />;
|
2026-08-18 09:21:12 +07:00
|
|
|
if (!status && isLoading)
|
|
|
|
|
return (
|
2026-08-24 15:44:10 +07:00
|
|
|
<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>
|
2026-08-18 09:21:12 +07:00
|
|
|
);
|
2026-08-15 17:53:48 +07:00
|
|
|
|
|
|
|
|
const connected = status?.connected ?? false;
|
|
|
|
|
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
|
2026-08-22 12:53:18 +07:00
|
|
|
const micBars = mic.micLevel
|
|
|
|
|
? Array.from({ length: 12 }, (_, i) =>
|
|
|
|
|
Math.max(
|
|
|
|
|
0.08,
|
|
|
|
|
Math.min(1, mic.micLevel * (1 - i * 0.06) + (i % 3) * 0.05),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: [];
|
2026-08-15 17:53:48 +07:00
|
|
|
|
|
|
|
|
const onConnect = async () => {
|
|
|
|
|
if (!guildId || !channelId) {
|
|
|
|
|
toast({ title: "Pick a guild + channel", tone: "vermilion" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await connect.mutateAsync({ guildId, channelId });
|
|
|
|
|
toast({ title: "Connected to voice", tone: "signal" });
|
|
|
|
|
} catch (e) {
|
2026-08-15 20:03:55 +07:00
|
|
|
toast({
|
|
|
|
|
title: "Connect failed",
|
|
|
|
|
description: String(e),
|
|
|
|
|
tone: "vermilion",
|
|
|
|
|
});
|
2026-08-14 12:35:44 +07:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
const onMic = async (on: boolean) => {
|
|
|
|
|
try {
|
|
|
|
|
await mic.mutateAsync(on);
|
|
|
|
|
setMicOn(on);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast({ title: "Mic error", description: String(e), tone: "vermilion" });
|
|
|
|
|
}
|
2026-08-14 11:28:08 +07:00
|
|
|
};
|
2026-08-07 10:44:03 +07:00
|
|
|
|
|
|
|
|
return (
|
2026-08-24 15:44:10 +07:00
|
|
|
<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} />
|
|
|
|
|
)}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
2026-08-24 15:44:10 +07:00
|
|
|
</section>
|
2026-08-15 17:53:48 +07:00
|
|
|
|
2026-08-24 15:44:10 +07:00
|
|
|
{/* 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>
|
|
|
|
|
)}
|
2026-08-15 17:53:48 +07:00
|
|
|
<Button
|
|
|
|
|
variant={micOn ? "primary" : "outline"}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => onMic(!micOn)}
|
|
|
|
|
disabled={mic.isPending}
|
|
|
|
|
aria-pressed={micOn}
|
|
|
|
|
>
|
|
|
|
|
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
|
|
|
|
{micOn ? "Mic live" : "Push-to-talk"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant={listen.active ? "primary" : "outline"}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => listen.toggle(!listen.active)}
|
|
|
|
|
>
|
2026-08-15 20:03:55 +07:00
|
|
|
{listen.active ? (
|
|
|
|
|
<Headphones className="size-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<Volume2 className="size-4" />
|
|
|
|
|
)}
|
2026-08-15 17:53:48 +07:00
|
|
|
{listen.active ? "Listening" : "Listen in"}
|
|
|
|
|
</Button>
|
2026-08-14 11:28:08 +07:00
|
|
|
</div>
|
|
|
|
|
|
2026-08-24 15:44:10 +07:00
|
|
|
{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)]"
|
2026-08-14 10:49:44 +07:00
|
|
|
/>
|
2026-08-24 15:44:10 +07:00
|
|
|
<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>
|
2026-08-07 10:44:03 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|