From 9019e2426327303eaf663caa39328e862db5f515 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 30 May 2026 16:20:41 +0700 Subject: [PATCH] feat: merge Voice, Media, and Recordings into unified Live panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/App.tsx | 102 ++----- frontend/src/components/layout/Header.tsx | 8 +- frontend/src/components/layout/Sidebar.tsx | 8 +- frontend/src/components/live/LivePanel.tsx | 314 +++++++++++++++++++++ frontend/src/hooks/useUIState.ts | 4 +- frontend/src/types/ui.ts | 2 +- 6 files changed, 343 insertions(+), 95 deletions(-) create mode 100644 frontend/src/components/live/LivePanel.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6555f4c..592aff1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const userTimelinesRef = useRef(new Map()); - 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 (
patchUIState({ activeTab: value as DashboardTab })}> -
+
{tabs.map((tab) => (
patchUIState({ activeTab: value as DashboardTab })}> - + {!isAuthenticated ? ( setIsAuthenticated(true)} /> ) : ( - patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })} onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })} onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)} onDisconnect={() => voice.leaveVoice()} onListenToggle={toggleListening} onStreamingToggle={toggleStreaming} - /> - )} - - - {!isAuthenticated ? ( - setIsAuthenticated(true)} /> - ) : ( - media.enqueue(source, "music")} onStartScreen={(source) => media.enqueue(source, "screen")} onSkip={media.skip} @@ -256,13 +203,6 @@ export default function App() { onReanalyze={messages.reanalyze} /> - - {!isAuthenticated ? ( - setIsAuthenticated(true)} /> - ) : ( - - )} - = { - 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 = { - 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.", }; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index f856212..ff46576 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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 }, ]; diff --git a/frontend/src/components/live/LivePanel.tsx b/frontend/src/components/live/LivePanel.tsx new file mode 100644 index 0000000..2c7e184 --- /dev/null +++ b/frontend/src/components/live/LivePanel.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
Loading recordings...
; + if (error) return
{error}
; + if (recordings.length === 0) return
No recordings found.
; + + return ( +
+ {recordings.map((rec) => ( +
+
+ +
+
+
{rec.filename}
+
+ {rec.username} + · + {rec.channel_name ?? rec.channel_id ?? "unknown"} + · + {formatDate(rec.created_at)} + · + {formatBytes(rec.size_bytes)} +
+ {rec.upload_error &&
{rec.upload_error}
} +
+
+ + {rec.upload_status} + + {rec.download_url && ( + + + + )} +
+
+ ))} +
+ ); +} + +// ─── 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 ( +
+ setSource(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="YouTube URL, Spotify track, or search terms" /> +
+ + setDraftVolume(Number(e.target.value))} className="h-2 w-full cursor-pointer accent-primary" /> + {draftVolume}% +
+
+ + + +
+
+ ); +} + +// ─── 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 ( +
+ setSource(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="Screen share URL or local file path" /> +
+ + + +
+
+ ); +} + +// ─── 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 ( +
+ {/* Voice Connection Controls */} + + + Voice Bridge + Join a Discord voice channel, listen, and transmit audio. + + +
+
+ + onChannelChange(e.target.value)} placeholder="Select voice channel" options={voiceChannels.map((c) => ({ value: c.id, label: c.name }))} /> +
+
+
+ + + + +
+
+
+ + {/* Audio Visualizer + Active Speakers */} +
+ + + Live Audio + + + + + + + + Active Speakers + + + {activeSpeakers.length === 0 ? ( +
No active speakers.
+ ) : ( +
+ {activeSpeakers.map((s, i) => ( +
+ +
+
{s.username}
+
Speaking
+
+
+ ))} +
+ )} +
+
+
+ + {/* Now Playing / Queue */} + {mediaState.current && ( + + + + {mediaState.current.mode === "screen" ? : } + Now Playing + + + +
+
+
{mediaState.current.title}
+
{mediaState.current.source}
+
+ {mediaState.current.mode || "music"} +
+ {mediaState.queue.length > 0 && ( +
+
Queue ({mediaState.queue.length})
+ {mediaState.queue.map((item, i) => ( +
+ {i + 1} +
+
{item.title}
+
{item.source}
+
+
+ ))} +
+ )} +
+
+ )} + + {/* Music + Screen Share + Recordings tabs */} + + + Music + Screen Share + Recordings + + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/hooks/useUIState.ts b/frontend/src/hooks/useUIState.ts index 5c0081f..eee2ece 100644 --- a/frontend/src/hooks/useUIState.ts +++ b/frontend/src/hooks/useUIState.ts @@ -3,7 +3,7 @@ import { getUIState, updateUIState } from "../api/uiState"; import type { UIState } from "../types/ui"; export function useUIState() { - const [uiState, setUIState] = useState({ activeTab: "voice" }); + const [uiState, setUIState] = useState({ activeTab: "live" }); const [loading, setLoading] = useState(true); const [error, setError] = useState(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)); diff --git a/frontend/src/types/ui.ts b/frontend/src/types/ui.ts index 80cf2ec..46373e2 100644 --- a/frontend/src/types/ui.ts +++ b/frontend/src/types/ui.ts @@ -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;