2026-08-07 10:44:03 +07:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import {
|
2026-08-15 20:03:55 +07:00
|
|
|
Headphones,
|
|
|
|
|
Mic,
|
|
|
|
|
MicOff,
|
|
|
|
|
PhoneOff,
|
|
|
|
|
Radio,
|
|
|
|
|
Volume2,
|
|
|
|
|
Waves,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { useEffect, 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,
|
|
|
|
|
LoadingState,
|
|
|
|
|
SectionHeader,
|
|
|
|
|
} from "@/components/shared";
|
|
|
|
|
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
|
|
|
|
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-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-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();
|
|
|
|
|
|
|
|
|
|
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-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-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} />;
|
|
|
|
|
if (!status && isLoading) return <LoadingState label="Linking voice" />;
|
|
|
|
|
|
|
|
|
|
const connected = status?.connected ?? false;
|
|
|
|
|
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
|
|
|
|
|
|
|
|
|
|
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-15 17:53:48 +07:00
|
|
|
<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 ? (
|
2026-08-15 20:03:55 +07:00
|
|
|
<Button
|
|
|
|
|
variant="danger"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => disconnect.mutate()}
|
|
|
|
|
disabled={disconnect.isPending}
|
|
|
|
|
>
|
2026-08-15 17:53:48 +07:00
|
|
|
<PhoneOff className="size-4" /> Disconnect
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
2026-08-15 20:03:55 +07:00
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={onConnect}
|
|
|
|
|
disabled={connect.isPending}
|
|
|
|
|
>
|
2026-08-15 17:53:48 +07:00
|
|
|
<Radio className="size-4" /> Connect
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
|
|
|
<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>
|
|
|
|
|
{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>
|
2026-08-14 11:28:08 +07:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-08-15 17:53:48 +07:00
|
|
|
</GlassPanel>
|
2026-08-14 11:28:08 +07:00
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
<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"}
|
2026-08-15 20:03:55 +07:00
|
|
|
description={
|
|
|
|
|
connected
|
|
|
|
|
? "Speakers appear as they talk."
|
|
|
|
|
: "Connect to a voice channel to see presence."
|
|
|
|
|
}
|
2026-08-14 10:49:44 +07:00
|
|
|
/>
|
2026-08-15 17:53:48 +07:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<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"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-08-15 20:03:55 +07:00
|
|
|
<span
|
|
|
|
|
className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`}
|
|
|
|
|
/>
|
2026-08-15 17:53:48 +07:00
|
|
|
{sp.username}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</GlassPanel>
|
|
|
|
|
|
|
|
|
|
<GlassPanel>
|
|
|
|
|
<SectionHeader eyebrow="links" title="Connections" />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{(status?.connections ?? []).map((c) => (
|
2026-08-15 20:03:55 +07:00
|
|
|
<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"
|
|
|
|
|
>
|
2026-08-15 17:53:48 +07:00
|
|
|
<span className="size-2 rounded-full bg-signal" />
|
2026-08-15 20:03:55 +07:00
|
|
|
<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>
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{(status?.connections ?? []).length === 0 && (
|
2026-08-15 20:03:55 +07:00
|
|
|
<div className="py-6 text-center text-xs text-ink-faint">
|
|
|
|
|
No active links
|
|
|
|
|
</div>
|
2026-08-15 17:53:48 +07:00
|
|
|
)}
|
|
|
|
|
</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>
|
2026-08-14 10:49:44 +07:00
|
|
|
</div>
|
2026-08-07 10:44:03 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|