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:
MythEclipse
2026-05-30 16:20:41 +07:00
co-authored by Claude Opus 4.8
parent c213300800
commit 9019e24263
6 changed files with 343 additions and 95 deletions
+21 -81
View File
@@ -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}