feat: merge Voice, Media, and Recordings into unified Live panel
- New LivePanel component combines voice bridge, media player, screen share, and recordings - Single page layout: voice controls → audio visualizer + active speakers → now playing → music/screen/recordings tabs - Reduced tabs from 6 to 4: Live, Messages, Analytics, Review - Sidebar updated with new tab structure - Default tab changed from 'voice' to 'live' - Cleaner compact layout with icon buttons and inline controls - Recordings sub-panel with user avatars, status badges, and download buttons Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c213300800
commit
9019e24263
+21
-81
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DashboardLayout } from "./components/layout/DashboardLayout";
|
||||
import { MediaPanel } from "./components/media/MediaPanel";
|
||||
import { LivePanel } from "./components/live/LivePanel";
|
||||
import { MessagesPanel } from "./components/messages/MessagesPanel";
|
||||
import { ReviewPanel } from "./components/review/ReviewPanel";
|
||||
import { Tabs, TabsContent } from "./components/ui/tabs";
|
||||
import { VoicePanel } from "./components/voice/VoicePanel";
|
||||
import { RecordingsPanel } from "./components/recordings/RecordingsPanel";
|
||||
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
||||
import { AnalyticsPanel } from "./components/analytics/AnalyticsPanel";
|
||||
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
||||
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
||||
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
||||
import { useMediaControl } from "./hooks/useMediaControl";
|
||||
@@ -36,7 +34,7 @@ export default function App() {
|
||||
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||
const userTimelinesRef = useRef(new Map<number, number>());
|
||||
|
||||
const activeTab = uiState.activeTab || "voice";
|
||||
const activeTab = uiState.activeTab || "live";
|
||||
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
|
||||
const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || "";
|
||||
@@ -86,18 +84,9 @@ export default function App() {
|
||||
|
||||
const stopStreamingLocal = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
}
|
||||
if (audioContextTransmitRef.current) {
|
||||
audioContextTransmitRef.current.close();
|
||||
audioContextTransmitRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) track.stop();
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (processorRef.current) { processorRef.current.disconnect(); processorRef.current = null; }
|
||||
if (audioContextTransmitRef.current) { audioContextTransmitRef.current.close(); audioContextTransmitRef.current = null; }
|
||||
if (streamRef.current) { for (const track of streamRef.current.getTracks()) track.stop(); streamRef.current = null; }
|
||||
setLevels(Array.from({ length: 32 }, () => 0.04));
|
||||
}, []);
|
||||
|
||||
@@ -106,29 +95,20 @@ export default function App() {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
setIsStreaming(true);
|
||||
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||
audioContextTransmitRef.current = audioContext;
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
processorRef.current = processor;
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (!socket.socketRef.current || socket.socketRef.current.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
const pcmData = new Int16Array(inputData.length);
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||
}
|
||||
for (let i = 0; i < inputData.length; i++) pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||
socket.socketRef.current.send(pcmData.buffer);
|
||||
|
||||
// Update local levels from mic
|
||||
let sum = 0;
|
||||
for (let i = 0; i < inputData.length; i++) sum += Math.abs(inputData[i]);
|
||||
const average = inputData.length ? sum / inputData.length : 0;
|
||||
@@ -142,41 +122,16 @@ export default function App() {
|
||||
}, [socket.socketRef]);
|
||||
|
||||
const toggleStreaming = useCallback(async () => {
|
||||
if (isStreaming) {
|
||||
stopStreamingLocal();
|
||||
await patchUIState({ isStreaming: false });
|
||||
} else {
|
||||
await startStreamingLocal();
|
||||
await patchUIState({ isStreaming: true });
|
||||
}
|
||||
if (isStreaming) { stopStreamingLocal(); await patchUIState({ isStreaming: false }); }
|
||||
else { await startStreamingLocal(); await patchUIState({ isStreaming: true }); }
|
||||
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVoiceGuild) {
|
||||
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||
}
|
||||
}, [selectedVoiceGuild]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTextGuild) {
|
||||
voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
|
||||
}
|
||||
}, [selectedTextGuild]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTextChannel) {
|
||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||
}
|
||||
}, [selectedTextChannel]);
|
||||
useEffect(() => { if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); }, [selectedVoiceGuild]);
|
||||
useEffect(() => { if (selectedTextGuild) voice.loadTextTargets(selectedTextGuild).catch(() => undefined); }, [selectedTextGuild]);
|
||||
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel]);
|
||||
|
||||
const toggleListening = useCallback(async () => {
|
||||
if (isListening) {
|
||||
await audioContextListenRef.current?.suspend();
|
||||
userTimelinesRef.current.clear();
|
||||
setIsListening(false);
|
||||
await patchUIState({ isListening: false });
|
||||
return;
|
||||
}
|
||||
if (isListening) { await audioContextListenRef.current?.suspend(); userTimelinesRef.current.clear(); setIsListening(false); await patchUIState({ isListening: false }); return; }
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||
await audioContextListenRef.current.resume();
|
||||
@@ -184,7 +139,7 @@ export default function App() {
|
||||
await patchUIState({ isListening: true });
|
||||
}, [isListening, patchUIState]);
|
||||
|
||||
const tabs = useMemo(() => ["voice", "media", "messages", "recordings", "analytics", "review"] as DashboardTab[], []);
|
||||
const tabs = useMemo(() => ["live", "messages", "analytics", "review"] as DashboardTab[], []);
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
@@ -195,7 +150,7 @@ export default function App() {
|
||||
>
|
||||
<div className="md:hidden">
|
||||
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||
<div className="mb-4 grid grid-cols-3 gap-1.5 rounded-2xl bg-muted p-1 sm:grid-cols-6">
|
||||
<div className="mb-4 grid grid-cols-4 gap-1.5 rounded-2xl bg-muted p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
|
||||
{tab}
|
||||
@@ -205,37 +160,29 @@ export default function App() {
|
||||
</Tabs>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||
<TabsContent value="voice">
|
||||
<TabsContent value="live">
|
||||
{!isAuthenticated ? (
|
||||
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<VoicePanel
|
||||
<LivePanel
|
||||
guilds={voice.guilds}
|
||||
channels={voice.voiceChannels}
|
||||
voiceChannels={voice.voiceChannels}
|
||||
selectedGuild={selectedVoiceGuild}
|
||||
selectedChannel={selectedVoiceChannel}
|
||||
status={voice.voiceStatus}
|
||||
loading={voice.loading}
|
||||
voiceLoading={voice.loading}
|
||||
activeSpeakers={activeSpeakers}
|
||||
levels={levels}
|
||||
isListening={isListening}
|
||||
isStreaming={isStreaming}
|
||||
mediaState={media.mediaState}
|
||||
mediaLoading={media.loading}
|
||||
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
|
||||
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
|
||||
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
|
||||
onDisconnect={() => voice.leaveVoice()}
|
||||
onListenToggle={toggleListening}
|
||||
onStreamingToggle={toggleStreaming}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="media">
|
||||
{!isAuthenticated ? (
|
||||
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<MediaPanel
|
||||
state={media.mediaState}
|
||||
loading={media.loading}
|
||||
onQueueMusic={(source) => media.enqueue(source, "music")}
|
||||
onStartScreen={(source) => media.enqueue(source, "screen")}
|
||||
onSkip={media.skip}
|
||||
@@ -256,13 +203,6 @@ export default function App() {
|
||||
onReanalyze={messages.reanalyze}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="recordings">
|
||||
{!isAuthenticated ? (
|
||||
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<RecordingsPanel />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="analytics">
|
||||
<AnalyticsPanel
|
||||
guilds={voice.guilds}
|
||||
|
||||
@@ -5,19 +5,15 @@ import type { VoiceStatus } from "../../types/voice";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
const titles: Record<DashboardTab, string> = {
|
||||
voice: "Voice Control",
|
||||
media: "Media Player",
|
||||
live: "Voice, Media & Recordings",
|
||||
messages: "Messages & Moderation",
|
||||
recordings: "Voice Recordings",
|
||||
analytics: "Analytics & Insights",
|
||||
review: "Moderation Review",
|
||||
};
|
||||
|
||||
const subtitles: Record<DashboardTab, string> = {
|
||||
voice: "Join voice channels and stream audio.",
|
||||
media: "Queue music, videos, and screen share.",
|
||||
live: "Join voice channels, play media, stream audio, and browse recordings.",
|
||||
messages: "Capture, analyse, and moderate Discord messages.",
|
||||
recordings: "Browse recorded voice segments.",
|
||||
analytics: "Server moderation statistics and trends.",
|
||||
review: "Review AI-flagged messages for moderation.",
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Bot, BarChart3, MessageSquare, Music2, ShieldAlert, Volume2, Mic } from "lucide-react";
|
||||
import { Bot, BarChart3, MessageSquare, ShieldAlert, Radio } from "lucide-react";
|
||||
import type { DashboardTab } from "../../types/ui";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }> = [
|
||||
{ id: "voice", label: "Voice", icon: Volume2 },
|
||||
{ id: "media", label: "Media", icon: Music2 },
|
||||
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> = [
|
||||
{ id: "live", label: "Live", icon: Radio },
|
||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||
{ id: "recordings", label: "Recordings", icon: Mic },
|
||||
{ id: "analytics", label: "Analytics", icon: BarChart3 },
|
||||
{ id: "review", label: "Review", icon: ShieldAlert },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ActiveSpeaker, Channel, Guild, VoiceStatus } from "../../types/voice";
|
||||
import type { MediaState } from "../../types/media";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Select } from "../ui/select";
|
||||
import { AudioVisualizer } from "../voice/AudioVisualizer";
|
||||
import { Music2, MonitorUp, Mic, Download, Headphones, Radio, SkipForward, Square, Volume2 } from "lucide-react";
|
||||
|
||||
// ─── Voice Recordings type ───
|
||||
interface VoiceRecording {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
download_url: string | null;
|
||||
upload_status: "pending" | "uploaded" | "failed";
|
||||
upload_error: string | null;
|
||||
created_at: number;
|
||||
uploaded_at: number | null;
|
||||
}
|
||||
|
||||
function formatDate(value: number): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// ─── Recordings Sub-Panel ───
|
||||
function RecordingsSubPanel() {
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useMemo(() => {
|
||||
let cancelled = false;
|
||||
async function loadRecordings() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch("/api/recordings");
|
||||
if (!response.ok) throw new Error(`Failed to load recordings: ${response.status}`);
|
||||
const data = (await response.json()) as VoiceRecording[];
|
||||
if (!cancelled) setRecordings(data);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
loadRecordings();
|
||||
window.addEventListener("voice_recording_uploaded", loadRecordings);
|
||||
return () => { cancelled = true; window.removeEventListener("voice_recording_uploaded", loadRecordings); };
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">Loading recordings...</div>;
|
||||
if (error) return <div className="rounded-xl border border-dashed border-destructive p-6 text-center text-sm text-destructive">{error}</div>;
|
||||
if (recordings.length === 0) return <div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No recordings found.</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((rec) => (
|
||||
<div key={rec.id} className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
<Mic className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{rec.filename}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||
<span>{rec.username}</span>
|
||||
<span>·</span>
|
||||
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(rec.created_at)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatBytes(rec.size_bytes)}</span>
|
||||
</div>
|
||||
{rec.upload_error && <div className="mt-1 text-xs text-destructive">{rec.upload_error}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={rec.upload_status === "uploaded" ? "success" : rec.upload_status === "failed" ? "destructive" : "secondary"}>
|
||||
{rec.upload_status}
|
||||
</Badge>
|
||||
{rec.download_url && (
|
||||
<a href={rec.download_url} target="_blank" rel="noreferrer" className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90">
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Music Player Sub-Panel ───
|
||||
function MusicSubPanel({ volume, onVolumeChange, onQueue, onSkip, onStop, loading }: {
|
||||
volume: number; onVolumeChange: (v: number) => void; onQueue: (s: string) => void;
|
||||
onSkip: () => void; onStop: () => void; loading: boolean;
|
||||
}) {
|
||||
const [source, setSource] = useState("");
|
||||
const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
|
||||
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const normalized = draftVolume / 100;
|
||||
if (Math.abs(normalized - safeVolume) >= 0.001) onVolumeChange(normalized);
|
||||
}, 200);
|
||||
return () => clearInterval(id);
|
||||
}, [draftVolume, safeVolume, onVolumeChange]);
|
||||
|
||||
const submit = () => { const t = source.trim(); if (!t) return; onQueue(t); setSource(""); };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input value={source} onChange={(e) => setSource(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="YouTube URL, Spotify track, or search terms" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input type="range" min={0} max={100} step={1} value={draftVolume} onChange={(e) => setDraftVolume(Number(e.target.value))} className="h-2 w-full cursor-pointer accent-primary" />
|
||||
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">{draftVolume}%</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}><Music2 className="mr-1.5 h-4 w-4" /> Queue</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}><SkipForward className="mr-1.5 h-4 w-4" /> Skip</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}><Square className="mr-1.5 h-4 w-4" /> Stop</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Screen Share Sub-Panel ───
|
||||
function ScreenSubPanel({ onStart, onSkip, onStop, loading }: {
|
||||
onStart: (s: string) => void; onSkip: () => void; onStop: () => void; loading: boolean;
|
||||
}) {
|
||||
const [source, setSource] = useState("");
|
||||
const submit = () => { const t = source.trim(); if (!t) return; onStart(t); setSource(""); };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input value={source} onChange={(e) => setSource(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="Screen share URL or local file path" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}><MonitorUp className="mr-1.5 h-4 w-4" /> Start</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}><SkipForward className="mr-1.5 h-4 w-4" /> Skip</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}><Square className="mr-1.5 h-4 w-4" /> Stop</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Unified Panel ───
|
||||
interface LivePanelProps {
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
voiceLoading: boolean;
|
||||
activeSpeakers: ActiveSpeaker[];
|
||||
levels: number[];
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
mediaState: MediaState;
|
||||
mediaLoading: boolean;
|
||||
onGuildChange: (id: string) => void;
|
||||
onChannelChange: (id: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
onQueueMusic: (s: string) => void;
|
||||
onStartScreen: (s: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
onVolumeChange: (v: number) => void;
|
||||
}
|
||||
|
||||
export function LivePanel({
|
||||
guilds, voiceChannels, selectedGuild, selectedChannel,
|
||||
status, voiceLoading, activeSpeakers, levels, isListening, isStreaming,
|
||||
mediaState, mediaLoading,
|
||||
onGuildChange, onChannelChange, onJoin, onDisconnect,
|
||||
onListenToggle, onStreamingToggle,
|
||||
onQueueMusic, onStartScreen, onSkip, onStop, onVolumeChange,
|
||||
}: LivePanelProps) {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{/* Voice Connection Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Radio className="h-5 w-5" /> Voice Bridge</CardTitle>
|
||||
<CardDescription>Join a Discord voice channel, listen, and transmit audio.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Guild</label>
|
||||
<Select value={selectedGuild} onChange={(e) => onGuildChange(e.target.value)} placeholder="Select guild" options={guilds.map((g) => ({ value: g.id, label: g.name }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Voice Channel</label>
|
||||
<Select value={selectedChannel} onChange={(e) => onChannelChange(e.target.value)} placeholder="Select voice channel" options={voiceChannels.map((c) => ({ value: c.id, label: c.name }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={!selectedGuild || !selectedChannel || voiceLoading} onClick={onJoin}>{status.connected ? "Reconnect" : "Join Voice"}</Button>
|
||||
<Button variant="destructive" disabled={!status.connected || voiceLoading} onClick={onDisconnect}>Disconnect</Button>
|
||||
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}><Headphones className="mr-1.5 h-4 w-4" /> {isListening ? "Stop Listening" : "Listen"}</Button>
|
||||
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}><Radio className="mr-1.5 h-4 w-4" /> {isStreaming ? "Stop Transmit" : "Transmit"}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audio Visualizer + Active Speakers */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_320px]">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Live Audio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AudioVisualizer levels={levels} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Active Speakers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeSpeakers.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No active speakers.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeSpeakers.map((s, i) => (
|
||||
<div key={s.userId || s.id || i} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
|
||||
<img src={s.avatar} alt="" className="h-8 w-8 rounded-full object-cover" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||
<div className="text-xs text-emerald-300">Speaking</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Now Playing / Queue */}
|
||||
{mediaState.current && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{mediaState.current.mode === "screen" ? <MonitorUp className="h-4 w-4" /> : <Music2 className="h-4 w-4" />}
|
||||
Now Playing
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/10 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{mediaState.current.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{mediaState.current.source}</div>
|
||||
</div>
|
||||
<Badge variant={mediaState.current.mode === "screen" ? "warning" : "success"}>{mediaState.current.mode || "music"}</Badge>
|
||||
</div>
|
||||
{mediaState.queue.length > 0 && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="text-sm font-medium">Queue ({mediaState.queue.length})</div>
|
||||
{mediaState.queue.map((item, i) => (
|
||||
<div key={`${item.source}-${i}`} className="flex items-center gap-3 rounded-lg border border-border bg-background/60 p-2.5 text-sm">
|
||||
<span className="h-5 w-5 flex shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">{i + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{item.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Music + Screen Share + Recordings tabs */}
|
||||
<Tabs defaultValue="music">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="music"><Music2 className="mr-1.5 h-4 w-4" /> Music</TabsTrigger>
|
||||
<TabsTrigger value="screen"><MonitorUp className="mr-1.5 h-4 w-4" /> Screen Share</TabsTrigger>
|
||||
<TabsTrigger value="recordings"><Mic className="mr-1.5 h-4 w-4" /> Recordings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="music">
|
||||
<MusicSubPanel volume={mediaState.musicVolume} onVolumeChange={onVolumeChange} onQueue={onQueueMusic} onSkip={onSkip} onStop={onStop} loading={mediaLoading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="screen">
|
||||
<ScreenSubPanel onStart={onStartScreen} onSkip={onSkip} onStop={onStop} loading={mediaLoading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="recordings">
|
||||
<RecordingsSubPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getUIState, updateUIState } from "../api/uiState";
|
||||
import type { UIState } from "../types/ui";
|
||||
|
||||
export function useUIState() {
|
||||
const [uiState, setUIState] = useState<UIState>({ activeTab: "voice" });
|
||||
const [uiState, setUIState] = useState<UIState>({ activeTab: "live" });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -11,7 +11,7 @@ export function useUIState() {
|
||||
let cancelled = false;
|
||||
getUIState()
|
||||
.then((state) => {
|
||||
if (!cancelled) setUIState({ activeTab: "voice", ...state });
|
||||
if (!cancelled) setUIState({ activeTab: "live", ...state });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type DashboardTab = "voice" | "media" | "messages" | "review" | "recordings" | "analytics";
|
||||
export type DashboardTab = "live" | "messages" | "review" | "analytics";
|
||||
|
||||
export interface UIState {
|
||||
selectedGuild?: string;
|
||||
|
||||
Reference in New Issue
Block a user