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}
+2 -6
View File
@@ -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.",
};
+3 -5
View File
@@ -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 },
];
+314
View File
@@ -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>
);
}
+2 -2
View File
@@ -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 -1
View File
@@ -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;