2026-07-26 15:34:08 +07:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
Headphones,
|
|
|
|
|
Loader2,
|
|
|
|
|
Mic,
|
|
|
|
|
Radio,
|
|
|
|
|
RadioOff,
|
|
|
|
|
UserCheck,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
2026-07-26 16:14:32 +07:00
|
|
|
import {
|
|
|
|
|
useGuilds,
|
|
|
|
|
useSpeakers,
|
|
|
|
|
useVoiceChannels,
|
|
|
|
|
useVoiceStatus,
|
|
|
|
|
} from "@/hooks";
|
2026-07-26 15:34:08 +07:00
|
|
|
import { voiceApi } from "@/lib/api";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { useWebSocket } from "@/lib/ws/context";
|
|
|
|
|
|
|
|
|
|
export default function VoicePage() {
|
|
|
|
|
const ws = useWebSocket();
|
2026-07-26 16:14:32 +07:00
|
|
|
const { voiceStatus, refresh: refreshStatus } = useVoiceStatus();
|
|
|
|
|
const { guilds } = useGuilds();
|
|
|
|
|
const { channels: voiceChannels, fetch: fetchChannels } = useVoiceChannels();
|
|
|
|
|
const { speakers, subscribe } = useSpeakers();
|
2026-07-26 15:34:08 +07:00
|
|
|
|
|
|
|
|
const [selectedGuild, setSelectedGuild] = useState("");
|
|
|
|
|
const [selectedChannel, setSelectedChannel] = useState("");
|
|
|
|
|
const [voiceLoading, setVoiceLoading] = useState(false);
|
|
|
|
|
const [micActive, setMicActive] = useState(false);
|
|
|
|
|
|
2026-07-26 16:14:32 +07:00
|
|
|
// Subscribe to WS speaker events
|
2026-07-26 15:34:08 +07:00
|
|
|
useEffect(() => {
|
2026-07-26 16:14:32 +07:00
|
|
|
const unsub = subscribe(ws);
|
|
|
|
|
return () => unsub();
|
|
|
|
|
}, [ws, subscribe]);
|
2026-07-26 15:34:08 +07:00
|
|
|
|
2026-07-26 16:14:32 +07:00
|
|
|
const handleGuildChange = useCallback(
|
|
|
|
|
(guildId: string | null) => {
|
|
|
|
|
if (!guildId) {
|
|
|
|
|
setSelectedGuild("");
|
|
|
|
|
setSelectedChannel("");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSelectedGuild(guildId);
|
|
|
|
|
setSelectedChannel("");
|
|
|
|
|
fetchChannels(guildId);
|
|
|
|
|
},
|
|
|
|
|
[fetchChannels],
|
|
|
|
|
);
|
2026-07-26 15:34:08 +07:00
|
|
|
|
|
|
|
|
const handleConnect = useCallback(async () => {
|
|
|
|
|
if (!selectedGuild || !selectedChannel) return;
|
|
|
|
|
setVoiceLoading(true);
|
|
|
|
|
try {
|
2026-07-26 16:14:32 +07:00
|
|
|
const _status = await voiceApi.connect(selectedGuild, selectedChannel);
|
|
|
|
|
// voiceStatus will be refreshed
|
|
|
|
|
setVoiceLoading(false);
|
|
|
|
|
refreshStatus();
|
2026-07-26 15:34:08 +07:00
|
|
|
} finally {
|
|
|
|
|
setVoiceLoading(false);
|
|
|
|
|
}
|
2026-07-26 16:14:32 +07:00
|
|
|
}, [selectedGuild, selectedChannel, refreshStatus]);
|
2026-07-26 15:34:08 +07:00
|
|
|
|
|
|
|
|
const handleDisconnect = useCallback(async () => {
|
|
|
|
|
setVoiceLoading(true);
|
|
|
|
|
try {
|
2026-07-26 16:14:32 +07:00
|
|
|
await voiceApi.disconnect();
|
|
|
|
|
refreshStatus();
|
2026-07-26 15:34:08 +07:00
|
|
|
} finally {
|
|
|
|
|
setVoiceLoading(false);
|
|
|
|
|
}
|
2026-07-26 16:14:32 +07:00
|
|
|
}, [refreshStatus]);
|
2026-07-26 15:34:08 +07:00
|
|
|
|
|
|
|
|
const activeSpeakers = speakers.filter((s) => s.speaking);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-5 animate-fade-in-up">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Radio className="size-4 text-primary" />
|
|
|
|
|
Voice Connection
|
|
|
|
|
</div>
|
|
|
|
|
<Badge
|
|
|
|
|
variant={voiceStatus?.connected ? "default" : "secondary"}
|
|
|
|
|
className={cn(
|
|
|
|
|
voiceStatus?.connected &&
|
|
|
|
|
"bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/20",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"size-1.5 rounded-full mr-1.5 inline-block",
|
|
|
|
|
voiceStatus?.connected
|
|
|
|
|
? "bg-green-500 shadow-[0_0_6px] shadow-green-500/60"
|
|
|
|
|
: "bg-muted-foreground",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{voiceStatus?.connected ? "Connected" : "Disconnected"}
|
|
|
|
|
</Badge>
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
{voiceStatus?.connected && voiceStatus.activeChannelName && (
|
|
|
|
|
<p className="text-sm text-muted-foreground flex items-center gap-1.5">
|
|
|
|
|
<Headphones className="size-4" />
|
|
|
|
|
Connected to{" "}
|
|
|
|
|
<span className="font-medium text-foreground">
|
|
|
|
|
{voiceStatus.activeChannelName}
|
|
|
|
|
</span>
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
2026-07-26 16:14:32 +07:00
|
|
|
<Select value={selectedGuild} onValueChange={handleGuildChange}>
|
2026-07-26 15:34:08 +07:00
|
|
|
<SelectTrigger className="flex-1 h-9">
|
2026-07-26 16:14:32 +07:00
|
|
|
<SelectValue placeholder="Select guild…" />
|
2026-07-26 15:34:08 +07:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{guilds.map((g) => (
|
|
|
|
|
<SelectItem key={g.id} value={g.id}>
|
|
|
|
|
{g.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<Select
|
|
|
|
|
value={selectedChannel}
|
|
|
|
|
onValueChange={(v) => v && setSelectedChannel(v)}
|
|
|
|
|
disabled={!selectedGuild}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="flex-1 h-9">
|
|
|
|
|
<SelectValue placeholder="Select channel…" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-07-26 16:14:32 +07:00
|
|
|
{voiceChannels.length === 0 ? (
|
|
|
|
|
<SelectItem value="_none" disabled>
|
|
|
|
|
No channels loaded
|
2026-07-26 15:34:08 +07:00
|
|
|
</SelectItem>
|
2026-07-26 16:14:32 +07:00
|
|
|
) : (
|
|
|
|
|
voiceChannels.map((c) => (
|
|
|
|
|
<SelectItem key={c.id} value={c.id}>
|
|
|
|
|
{c.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
|
|
|
|
)}
|
2026-07-26 15:34:08 +07:00
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{voiceStatus?.connected ? (
|
|
|
|
|
<Button
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={handleDisconnect}
|
|
|
|
|
disabled={voiceLoading}
|
|
|
|
|
>
|
|
|
|
|
{voiceLoading ? (
|
|
|
|
|
<Loader2 className="size-4 animate-spin mr-1.5" />
|
|
|
|
|
) : (
|
|
|
|
|
<RadioOff className="size-4 mr-1.5" />
|
|
|
|
|
)}
|
|
|
|
|
Disconnect
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleConnect}
|
2026-07-26 15:39:41 +07:00
|
|
|
disabled={voiceLoading || !selectedGuild || !selectedChannel}
|
2026-07-26 15:34:08 +07:00
|
|
|
>
|
|
|
|
|
{voiceLoading ? (
|
|
|
|
|
<Loader2 className="size-4 animate-spin mr-1.5" />
|
|
|
|
|
) : (
|
|
|
|
|
<Radio className="size-4 mr-1.5" />
|
|
|
|
|
)}
|
|
|
|
|
Connect
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{activeSpeakers.length > 0 && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
|
|
|
<UserCheck className="size-4 text-primary" />
|
|
|
|
|
Active Speakers
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
{activeSpeakers.map((s) => (
|
|
|
|
|
<div
|
|
|
|
|
key={s.userId}
|
|
|
|
|
className="flex items-center gap-2 rounded-full border border-border/50 bg-card px-3 py-1.5 shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
<span className="relative flex size-2">
|
|
|
|
|
<span className="absolute inline-flex size-full rounded-full bg-green-400 opacity-75 live-pulse-ring" />
|
|
|
|
|
<span className="relative inline-flex size-2 rounded-full bg-green-500" />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-sm">{s.username}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
|
|
|
<Mic className="size-4 text-primary" />
|
|
|
|
|
Microphone
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{micActive ? "On" : "Off"}
|
|
|
|
|
</span>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={micActive}
|
|
|
|
|
onCheckedChange={async (checked) => {
|
|
|
|
|
setMicActive(checked);
|
|
|
|
|
try {
|
|
|
|
|
await voiceApi.sendCommand(
|
2026-07-26 15:39:41 +07:00
|
|
|
checked ? "voice:transmit:start" : "voice:transmit:stop",
|
2026-07-26 15:34:08 +07:00
|
|
|
);
|
2026-07-26 16:34:06 +07:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error("voice/mic:", err);
|
2026-07-26 15:34:08 +07:00
|
|
|
setMicActive(!checked);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
disabled={!voiceStatus?.connected}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{!voiceStatus?.connected && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Connect to a voice channel first.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{micActive && (
|
|
|
|
|
<div className="flex items-center gap-2 mt-1">
|
|
|
|
|
<span className="relative flex size-2">
|
|
|
|
|
<span className="absolute inline-flex size-full rounded-full bg-red-400 opacity-75 live-pulse-ring" />
|
|
|
|
|
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
Transmitting…
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|