2026-08-07 10:44:03 +07:00
|
|
|
"use client";
|
|
|
|
|
|
2026-08-14 10:49:44 +07:00
|
|
|
import {
|
|
|
|
|
Activity,
|
|
|
|
|
Headphones,
|
|
|
|
|
Mic,
|
|
|
|
|
MicOff,
|
|
|
|
|
Pause,
|
|
|
|
|
Play,
|
|
|
|
|
Settings,
|
|
|
|
|
Wifi,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
import { SessionRibbon } from "@/components/charts/session-ribbon";
|
|
|
|
|
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
|
|
|
|
|
import { Badge } from "@/components/primitives/badge";
|
|
|
|
|
import { Button } from "@/components/primitives/button";
|
|
|
|
|
import { SignalField } from "@/components/three";
|
|
|
|
|
import { StaticFallback } from "@/components/three/static-fallback";
|
|
|
|
|
import { WebGLGuard } from "@/components/three/webgl-guard";
|
|
|
|
|
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
|
2026-08-07 10:44:03 +07:00
|
|
|
import { ListenControl } from "@/components/voice/listen-control";
|
|
|
|
|
import { MicControl } from "@/components/voice/mic-control";
|
|
|
|
|
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
|
|
|
|
|
import {
|
|
|
|
|
useSpeakers,
|
|
|
|
|
useVoiceConnect,
|
|
|
|
|
useVoiceDisconnect,
|
|
|
|
|
useVoiceListen,
|
|
|
|
|
} from "@/hooks";
|
2026-08-14 10:49:44 +07:00
|
|
|
import type { ActiveSpeaker, VoiceStatus } from "@/lib/types";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-08-07 10:44:03 +07:00
|
|
|
import { useWebSocket } from "@/lib/ws/context";
|
|
|
|
|
|
2026-08-14 10:49:44 +07:00
|
|
|
type VoiceTab = "stage" | "activity";
|
2026-08-07 10:44:03 +07:00
|
|
|
|
|
|
|
|
export default function VoiceView({
|
|
|
|
|
initialStatus,
|
|
|
|
|
}: {
|
|
|
|
|
initialStatus?: VoiceStatus;
|
|
|
|
|
}) {
|
|
|
|
|
const ws = useWebSocket();
|
2026-08-14 10:49:44 +07:00
|
|
|
const [tab, setTab] = useState<VoiceTab>("stage");
|
|
|
|
|
|
|
|
|
|
const initialSpeakers = useMemo(
|
|
|
|
|
() => initialStatus?.activeSpeakers ?? [],
|
|
|
|
|
[initialStatus],
|
|
|
|
|
);
|
|
|
|
|
const { speakers, subscribe } = useSpeakers(initialSpeakers);
|
|
|
|
|
const connect = useVoiceConnect();
|
|
|
|
|
const disconnect = useVoiceDisconnect();
|
2026-08-07 10:44:03 +07:00
|
|
|
const listen = useVoiceListen(ws);
|
|
|
|
|
|
|
|
|
|
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-14 10:49:44 +07:00
|
|
|
const active = speakers.filter((s) => s.speaking);
|
2026-08-07 10:44:03 +07:00
|
|
|
|
|
|
|
|
return (
|
2026-08-14 10:49:44 +07:00
|
|
|
<div className="flex flex-col gap-5">
|
|
|
|
|
{/* Connection bar */}
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Badge tone={initialStatus?.connected ? "signal" : "neutral"} dot>
|
|
|
|
|
{initialStatus?.connected ? "Connected" : "Disconnected"}
|
|
|
|
|
</Badge>
|
|
|
|
|
{initialStatus?.activeChannelName && (
|
|
|
|
|
<span className="text-sm text-[var(--color-ink-soft)]">
|
|
|
|
|
#{initialStatus.activeChannelName}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{initialStatus?.connected ? (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => disconnect.mutate()}
|
|
|
|
|
>
|
|
|
|
|
Disconnect
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="primary"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
connect.mutate({
|
|
|
|
|
guildId: initialStatus?.activeGuildId ?? "",
|
|
|
|
|
channelId: initialStatus?.activeChannelId ?? "",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Connect
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-08-07 10:44:03 +07:00
|
|
|
|
2026-08-14 10:49:44 +07:00
|
|
|
{/* Stage hero */}
|
|
|
|
|
<div className="surface relative flex h-[280px] items-end justify-center overflow-hidden rounded-[var(--radius-r)] p-5">
|
|
|
|
|
<WebGLGuard
|
|
|
|
|
fallback={
|
|
|
|
|
<StaticFallback
|
|
|
|
|
variant="orb"
|
|
|
|
|
count={Math.max(speakers.length, 3)}
|
|
|
|
|
className="absolute inset-0"
|
|
|
|
|
/>
|
2026-08-07 10:44:03 +07:00
|
|
|
}
|
2026-08-14 10:49:44 +07:00
|
|
|
>
|
|
|
|
|
<SignalField
|
|
|
|
|
activity={speakers.length > 0 ? 0.6 : 0.2}
|
|
|
|
|
className="absolute inset-0"
|
|
|
|
|
/>
|
|
|
|
|
</WebGLGuard>
|
|
|
|
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
|
|
|
|
|
<SpeakerWaveform speakers={active} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-08-07 10:44:03 +07:00
|
|
|
|
2026-08-14 10:49:44 +07:00
|
|
|
<StaggerGroup className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
|
|
|
|
<StaggerItem>
|
|
|
|
|
<MicControl
|
|
|
|
|
micOn={listen.active}
|
|
|
|
|
onToggle={(on) => listen.toggle(on)}
|
|
|
|
|
levels={listen.levels}
|
|
|
|
|
/>
|
|
|
|
|
</StaggerItem>
|
|
|
|
|
<StaggerItem>
|
|
|
|
|
<ListenControl
|
|
|
|
|
listening={listen.active}
|
|
|
|
|
onToggle={(on) => listen.toggle(on)}
|
|
|
|
|
volume={75}
|
|
|
|
|
onVolume={listen.setVolume}
|
|
|
|
|
/>
|
|
|
|
|
</StaggerItem>
|
|
|
|
|
</StaggerGroup>
|
|
|
|
|
|
|
|
|
|
{/* Tabs */}
|
|
|
|
|
<div className="flex gap-1 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-1">
|
|
|
|
|
{(["stage", "activity"] as const).map((t) => (
|
|
|
|
|
<button
|
|
|
|
|
key={t}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setTab(t)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-1.5 rounded-[var(--radius-r-control)] px-3 py-1.5 text-xs font-medium transition-colors",
|
|
|
|
|
tab === t
|
|
|
|
|
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
|
|
|
|
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{t === "stage" ? (
|
|
|
|
|
<Activity className="size-3.5" />
|
|
|
|
|
) : (
|
|
|
|
|
<Headphones className="size-3.5" />
|
|
|
|
|
)}
|
|
|
|
|
{t === "stage" ? "Stage" : "Activity"}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{tab === "stage" && <ActiveSpeakersPanel speakers={speakers} />}
|
|
|
|
|
{tab === "activity" && (
|
|
|
|
|
<div className="surface p-4">
|
|
|
|
|
<h3 className="mb-3 text-sm font-semibold">Live session timeline</h3>
|
|
|
|
|
<SessionRibbon
|
|
|
|
|
segments={speakers.map((s) => ({
|
|
|
|
|
id: s.userId,
|
|
|
|
|
label: s.username,
|
|
|
|
|
value: s.speaking ? 3 : 1,
|
|
|
|
|
tone: s.speaking ? "signal" : "neutral",
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
2026-08-07 10:44:03 +07:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|