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:
@@ -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="surface flex flex-col gap-3 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge tone={initialStatus?.connected ? "signal" : "neutral"} dot>
|
<Badge tone={connected ? "signal" : "neutral"} dot>
|
||||||
{initialStatus?.connected ? "Connected" : "Disconnected"}
|
{connected ? "Connected" : "Disconnected"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{initialStatus?.activeChannelName && (
|
{connected && initialStatus?.activeChannelName && (
|
||||||
<span className="text-sm text-[var(--color-ink-soft)]">
|
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
|
||||||
#{initialStatus.activeChannelName}
|
<Headphones className="size-3.5" />
|
||||||
|
{initialStatus.activeChannelName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{initialStatus?.connected ? (
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
{!connected ? (
|
||||||
variant="outline"
|
<div className="flex flex-wrap gap-2">
|
||||||
onClick={() => disconnect.mutate()}
|
<Select
|
||||||
|
value={selectedGuild}
|
||||||
|
onChange={handleGuildChange}
|
||||||
|
className="flex-1 min-w-[140px] h-9"
|
||||||
>
|
>
|
||||||
Disconnect
|
<option value="" disabled>
|
||||||
</Button>
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
connect.mutate({
|
void connect.mutate({
|
||||||
guildId: initialStatus?.activeGuildId ?? "",
|
guildId: selectedGuild,
|
||||||
channelId: initialStatus?.activeChannelId ?? "",
|
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
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => void disconnect.mutate(undefined)}
|
||||||
|
disabled={disconnect.isPending}
|
||||||
|
>
|
||||||
|
{disconnect.isPending ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<RadioOff className="size-3.5 mr-1.5" />
|
||||||
|
)}
|
||||||
|
Disconnect
|
||||||
|
</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,34 +179,11 @@ 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">
|
|
||||||
{(["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">
|
<div className="surface p-4">
|
||||||
<h3 className="mb-3 text-sm font-semibold">Live session timeline</h3>
|
<h3 className="mb-3 text-sm font-semibold">
|
||||||
|
Live session timeline
|
||||||
|
</h3>
|
||||||
<SessionRibbon
|
<SessionRibbon
|
||||||
segments={speakers.map((s) => ({
|
segments={speakers.map((s) => ({
|
||||||
id: s.userId,
|
id: s.userId,
|
||||||
@@ -170,7 +193,8 @@ export default function VoiceView({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<ActiveSpeakersPanel speakers={speakers} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user