fix(frontend): restore voice guild/channel picker + media URL queue input

- voice/view: add Select for guild + voice channels + Connect/Disconnect bar
- media/view: restore URL queue input + Screen toggle + Queue button
(tsc clean, next build green)
This commit is contained in:
asepharyana
2026-08-14 11:28:08 +07:00
parent 8b281c7feb
commit d65dc11c73
2 changed files with 164 additions and 127 deletions
@@ -1,30 +1,23 @@
"use client"; "use client";
import { import { Pause, Play, Repeat2, SkipForward, Square, Volume2 } from "lucide-react";
Pause,
Play,
Repeat2,
SkipForward,
Square,
Volume2,
} from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useEffect } from "react"; import { useState } from "react";
import { Waveform } from "@/components/charts/waveform"; import { Waveform } from "@/components/charts/waveform";
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger"; import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge"; import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button"; import { Button } from "@/components/primitives/button";
import { Input } from "@/components/primitives/input";
import { Progress } from "@/components/primitives/progress"; import { Progress } from "@/components/primitives/progress";
import { import {
useMediaLoop, useMediaLoop,
useMediaQueue,
useMediaSkip, useMediaSkip,
useMediaState, useMediaState,
useMediaStop, useMediaStop,
useMediaWsSync, useMediaWsSync,
} from "@/hooks"; } from "@/hooks";
import type { MediaState } from "@/lib/types"; import type { MediaState } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
export default function MediaView({ export default function MediaView({
@@ -33,7 +26,8 @@ export default function MediaView({
initialStatus?: MediaState; initialStatus?: MediaState;
}) { }) {
const ws = useWebSocket(); const ws = useWebSocket();
const { data: state, mutate } = useMediaState(initialStatus); const { data: state } = useMediaState(initialStatus);
const queueMut = useMediaQueue();
const skip = useMediaSkip(); const skip = useMediaSkip();
const stop = useMediaStop(); const stop = useMediaStop();
const loopMut = useMediaLoop(); const loopMut = useMediaLoop();
@@ -45,23 +39,55 @@ export default function MediaView({
const loop = state?.loop ?? false; const loop = state?.loop ?? false;
const duration = current?.durationMs ?? 0; const duration = current?.durationMs ?? 0;
const [seed] = useWaveformSeed(); const [queueUrl, setQueueUrl] = useState("");
const [screenMode, setScreenMode] = useState(false);
const handleQueue = () => {
if (!queueUrl.trim()) return;
queueMut.mutate({ url: queueUrl.trim(), mode: screenMode ? "screen" : "music" });
setQueueUrl("");
};
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* URL queue input */}
<div className="flex gap-2">
<Input
placeholder="Queue a URL (YouTube, audio file…)"
value={queueUrl}
onChange={(e) => setQueueUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
className="flex-1 h-9"
/>
<Button
size="sm"
variant={screenMode ? "primary" : "ghost"}
onClick={() => setScreenMode((v) => !v)}
title="Queue as Discord GoLive screenshare instead of audio playback"
>
Screen
</Button>
<Button
size="sm"
onClick={handleQueue}
disabled={!queueUrl.trim() || queueMut.isPending}
>
<Play className="size-4 mr-1.5" />
Queue
</Button>
</div>
{/* Turntable hero */} {/* Turntable hero */}
<div className="flex items-center gap-6 surface scan-tick flex-wrap p-5"> <div className="flex items-center gap-6 surface scan-tick flex-wrap p-5">
{current && ( {current && (
<motion.div <motion.div
className={cn( className={`relative mx-auto size-[160px] rounded-full ${
"relative mx-auto size-[160px] rounded-full", playing ? "animate-spin-disc" : "animate-spin-disc paused"
playing && "animate-spin-disc", }`}
!playing && "animate-spin-disc paused",
)}
> >
<img <img
src={current.thumbnailUrl ?? "/favicon.ico"} src={current.thumbnailUrl ?? "/favicon.ico"}
alt={current.title} alt={current.title ?? "cover"}
className="size-full rounded-full object-cover ring-4 ring-[var(--color-signal)]/20" className="size-full rounded-full object-cover ring-4 ring-[var(--color-signal)]/20"
style={{ animationPlayState: playing ? "running" : "paused" }} style={{ animationPlayState: playing ? "running" : "paused" }}
/> />
@@ -97,11 +123,7 @@ export default function MediaView({
variant="primary" variant="primary"
onClick={() => loopMut.mutate(!loop)} onClick={() => loopMut.mutate(!loop)}
> >
{playing ? ( {playing ? <Pause className="size-5" /> : <Play className="size-5" />}
<Pause className="size-5" />
) : (
<Play className="size-5" />
)}
</Button> </Button>
</StaggerItem> </StaggerItem>
<StaggerItem> <StaggerItem>
@@ -131,9 +153,9 @@ export default function MediaView({
<Badge tone="neutral">{loop ? "loop" : "queue"}</Badge> <Badge tone="neutral">{loop ? "loop" : "queue"}</Badge>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{queue.map((item, i) => ( {queue.map((item) => (
<motion.div <motion.div
key={item.id ?? i} key={item.id ?? item.source}
layout layout
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
@@ -141,7 +163,7 @@ export default function MediaView({
className="flex items-center gap-2.5 rounded-[var(--radius-r-control)] px-2 py-1.5 text-sm hover:bg-[var(--color-surface-2)]" className="flex items-center gap-2.5 rounded-[var(--radius-r-control)] px-2 py-1.5 text-sm hover:bg-[var(--color-surface-2)]"
> >
<Waveform <Waveform
seed={item.id ?? String(i)} seed={item.id ?? item.source}
bars={12} bars={12}
height={20} height={20}
className="w-16" className="w-16"
@@ -161,12 +183,3 @@ function formatMs(ms: number): string {
const s = Math.floor((ms % 60000) / 1000); const s = Math.floor((ms % 60000) / 1000);
return `${m}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`;
} }
function useWaveformSeed() {
const [seed] = useStateValue();
return [seed];
}
function useStateValue(): [string] {
// lightweight deterministic seed so waveform shape is stable per session
return ["media-waveform"];
}
@@ -1,52 +1,42 @@
"use client"; "use client";
import { import { Headphones, Loader2, Radio, RadioOff } from "lucide-react";
Activity,
Headphones,
Mic,
MicOff,
Pause,
Play,
Settings,
Wifi,
} from "lucide-react";
import { useEffect, useMemo, useState } from "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 { SignalField } from "@/components/three";
import { StaticFallback } from "@/components/three/static-fallback";
import { WebGLGuard } from "@/components/three/webgl-guard"; import { WebGLGuard } from "@/components/three/webgl-guard";
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel"; import { StaticFallback } from "@/components/three/static-fallback";
import { ListenControl } from "@/components/voice/listen-control"; import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
import { MicControl } from "@/components/voice/mic-control"; import { Button } from "@/components/primitives/button";
import { Badge } from "@/components/primitives/badge";
import { Select } from "@/components/primitives/select";
import { SpeakerWaveform } from "@/components/voice/speaker-waveform"; import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
import { SessionRibbon } from "@/components/charts/session-ribbon";
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
import { MicControl } from "@/components/voice/mic-control";
import { ListenControl } from "@/components/voice/listen-control";
import { import {
useGuilds,
useSpeakers, useSpeakers,
useVoiceChannels,
useVoiceConnect, useVoiceConnect,
useVoiceDisconnect, useVoiceDisconnect,
useVoiceListen, useVoiceListen,
} from "@/hooks"; } from "@/hooks";
import type { ActiveSpeaker, VoiceStatus } from "@/lib/types"; import type { VoiceStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
type VoiceTab = "stage" | "activity"; export default function VoiceView({ initialStatus }: { initialStatus?: VoiceStatus }) {
export default function VoiceView({
initialStatus,
}: {
initialStatus?: VoiceStatus;
}) {
const ws = useWebSocket(); const ws = useWebSocket();
const [tab, setTab] = useState<VoiceTab>("stage"); const [selectedGuild, setSelectedGuild] = useState("");
const [selectedChannel, setSelectedChannel] = useState("");
const initialSpeakers = useMemo( const initialSpeakers = useMemo(
() => initialStatus?.activeSpeakers ?? [], () => initialStatus?.activeSpeakers ?? [],
[initialStatus], [initialStatus],
); );
const { speakers, subscribe } = useSpeakers(initialSpeakers); const { speakers, subscribe } = useSpeakers(initialSpeakers);
const { data: guilds = [] } = useGuilds();
const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild);
const connect = useVoiceConnect(); const connect = useVoiceConnect();
const disconnect = useVoiceDisconnect(); const disconnect = useVoiceDisconnect();
const listen = useVoiceListen(ws); const listen = useVoiceListen(ws);
@@ -57,45 +47,101 @@ export default function VoiceView({
}, [subscribe, ws]); }, [subscribe, ws]);
const active = speakers.filter((s) => s.speaking); const active = speakers.filter((s) => s.speaking);
const connected = initialStatus?.connected ?? false;
const handleGuildChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const g = e.target.value;
setSelectedGuild(g);
setSelectedChannel("");
};
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* Connection bar */} {/* Connection bar with guild + voice channel pickers */}
<div className="flex items-center gap-3"> <div className="surface flex flex-col gap-3 p-4">
<Badge tone={initialStatus?.connected ? "signal" : "neutral"} dot> <div className="flex items-center gap-3">
{initialStatus?.connected ? "Connected" : "Disconnected"} <Badge tone={connected ? "signal" : "neutral"} dot>
</Badge> {connected ? "Connected" : "Disconnected"}
{initialStatus?.activeChannelName && ( </Badge>
<span className="text-sm text-[var(--color-ink-soft)]"> {connected && initialStatus?.activeChannelName && (
#{initialStatus.activeChannelName} <span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
</span> <Headphones className="size-3.5" />
)} {initialStatus.activeChannelName}
{initialStatus?.connected ? ( </span>
<Button )}
size="sm" </div>
variant="outline"
onClick={() => disconnect.mutate()} {!connected ? (
> <div className="flex flex-wrap gap-2">
Disconnect <Select
</Button> value={selectedGuild}
onChange={handleGuildChange}
className="flex-1 min-w-[140px] h-9"
>
<option value="" disabled>
Select guild
</option>
{guilds.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Select>
<Select
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
disabled={!selectedGuild}
className="flex-1 min-w-[140px] h-9"
>
<option value="" disabled>
Select channel
</option>
{(voiceChannels ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<Button
size="sm"
variant="primary"
onClick={() =>
void connect.mutate({
guildId: selectedGuild,
channelId: selectedChannel,
})
}
disabled={connect.isPending || !selectedGuild || !selectedChannel}
>
{connect.isPending ? (
<Loader2 className="size-3.5 animate-spin mr-1.5" />
) : (
<Radio className="size-3.5 mr-1.5" />
)}
Connect
</Button>
</div>
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="primary" variant="danger"
onClick={() => onClick={() => void disconnect.mutate(undefined)}
connect.mutate({ disabled={disconnect.isPending}
guildId: initialStatus?.activeGuildId ?? "",
channelId: initialStatus?.activeChannelId ?? "",
})
}
> >
Connect {disconnect.isPending ? (
<Loader2 className="size-3.5 animate-spin mr-1.5" />
) : (
<RadioOff className="size-3.5 mr-1.5" />
)}
Disconnect
</Button> </Button>
)} )}
</div> </div>
{/* Stage hero */} {/* Stage hero */}
<div className="surface relative flex h-[280px] items-end justify-center overflow-hidden rounded-[var(--radius-r)] p-5"> <div className="relative surface h-[280px] items-end justify-center overflow-hidden rounded-[var(--radius-r)] p-5">
<WebGLGuard <WebGLGuard
fallback={ fallback={
<StaticFallback <StaticFallback
@@ -133,44 +179,22 @@ export default function VoiceView({
</StaggerItem> </StaggerItem>
</StaggerGroup> </StaggerGroup>
{/* Tabs */} {/* Activity timeline */}
<div className="flex gap-1 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-1"> <div className="surface p-4">
{(["stage", "activity"] as const).map((t) => ( <h3 className="mb-3 text-sm font-semibold">
<button Live session timeline
key={t} </h3>
type="button" <SessionRibbon
onClick={() => setTab(t)} segments={speakers.map((s) => ({
className={cn( id: s.userId,
"flex items-center gap-1.5 rounded-[var(--radius-r-control)] px-3 py-1.5 text-xs font-medium transition-colors", label: s.username,
tab === t value: s.speaking ? 3 : 1,
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]" tone: s.speaking ? "signal" : "neutral",
: "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> </div>
{tab === "stage" && <ActiveSpeakersPanel speakers={speakers} />} <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",
}))}
/>
</div>
)}
</div> </div>
); );
} }