2026-05-16 19:17:34 +07:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
import { DashboardLayout } from "./components/layout/DashboardLayout";
|
2026-05-30 16:20:41 +07:00
|
|
|
import { LivePanel } from "./components/live/LivePanel";
|
2026-05-16 19:17:34 +07:00
|
|
|
import { MessagesPanel } from "./components/messages/MessagesPanel";
|
|
|
|
|
import { Tabs, TabsContent } from "./components/ui/tabs";
|
2026-05-29 19:37:08 +07:00
|
|
|
import { AnalyticsPanel } from "./components/analytics/AnalyticsPanel";
|
2026-05-30 16:20:41 +07:00
|
|
|
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
2026-05-16 19:17:34 +07:00
|
|
|
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
|
|
|
|
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
|
|
|
|
import { useMediaControl } from "./hooks/useMediaControl";
|
|
|
|
|
import { useUIState } from "./hooks/useUIState";
|
|
|
|
|
import { useVoiceControl } from "./hooks/useVoiceControl";
|
|
|
|
|
import type { MessageRecord } from "./types/messages";
|
|
|
|
|
import type { DashboardTab } from "./types/ui";
|
|
|
|
|
import type { ActiveSpeaker } from "./types/voice";
|
2026-05-14 20:46:21 +07:00
|
|
|
|
2026-05-16 19:17:34 +07:00
|
|
|
const SAMPLE_RATE = 24000;
|
|
|
|
|
const CHANNELS = 1;
|
2026-05-15 06:52:20 +07:00
|
|
|
|
2026-05-14 20:24:41 +07:00
|
|
|
export default function App() {
|
2026-05-16 19:17:34 +07:00
|
|
|
const { uiState, setUIState, patchUIState } = useUIState();
|
|
|
|
|
const voice = useVoiceControl();
|
|
|
|
|
const media = useMediaControl();
|
|
|
|
|
const messages = useMessages();
|
|
|
|
|
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
|
|
|
|
|
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
|
|
|
|
|
const [isListening, setIsListening] = useState(false);
|
2026-05-16 21:08:39 +07:00
|
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
2026-05-17 00:20:29 +07:00
|
|
|
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
|
2026-05-16 21:08:39 +07:00
|
|
|
const audioContextListenRef = useRef<AudioContext | null>(null);
|
|
|
|
|
const audioContextTransmitRef = useRef<AudioContext | null>(null);
|
|
|
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
|
|
|
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
2026-05-16 19:17:34 +07:00
|
|
|
const userTimelinesRef = useRef(new Map<number, number>());
|
|
|
|
|
|
2026-05-30 16:20:41 +07:00
|
|
|
const activeTab = uiState.activeTab || "live";
|
2026-05-16 19:17:34 +07:00
|
|
|
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
|
|
|
|
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
|
|
|
|
|
const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || "";
|
|
|
|
|
const selectedTextChannel = uiState.selectedTextChannel || "";
|
|
|
|
|
|
|
|
|
|
const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
|
|
|
|
|
const headerView = new DataView(data, 0, 4);
|
|
|
|
|
const userIdHash = headerView.getInt32(0, true);
|
|
|
|
|
const audioData = data.slice(4);
|
|
|
|
|
const int16Array = new Int16Array(audioData);
|
|
|
|
|
let sum = 0;
|
|
|
|
|
for (const sample of int16Array) sum += Math.abs(sample / 32768);
|
|
|
|
|
const average = int16Array.length ? sum / int16Array.length : 0;
|
|
|
|
|
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
|
|
|
|
|
|
2026-05-16 21:08:39 +07:00
|
|
|
const audioContext = audioContextListenRef.current;
|
2026-05-16 19:17:34 +07:00
|
|
|
if (!isListening || !audioContext) return;
|
|
|
|
|
const float32Array = new Float32Array(int16Array.length);
|
|
|
|
|
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
|
|
|
|
|
const audioBuffer = audioContext.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE);
|
|
|
|
|
audioBuffer.getChannelData(0).set(float32Array);
|
|
|
|
|
const source = audioContext.createBufferSource();
|
|
|
|
|
source.buffer = audioBuffer;
|
|
|
|
|
source.connect(audioContext.destination);
|
|
|
|
|
const currentTime = audioContext.currentTime;
|
|
|
|
|
let nextStart = userTimelinesRef.current.get(userIdHash) || 0;
|
|
|
|
|
if (nextStart < currentTime) nextStart = currentTime + 0.05;
|
|
|
|
|
source.start(nextStart);
|
|
|
|
|
userTimelinesRef.current.set(userIdHash, nextStart + audioBuffer.duration);
|
|
|
|
|
}, [isListening]);
|
|
|
|
|
|
|
|
|
|
const socket = useDashboardSocket({
|
|
|
|
|
onUIState: (state) => setUIState((prev) => ({ ...prev, ...state })),
|
|
|
|
|
onUserState: setActiveSpeakers,
|
|
|
|
|
onMessageCreated: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
|
|
|
|
|
onMessageUpdated: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, ...message } as MessageRecord : item))),
|
|
|
|
|
onMessageDeleted: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, type: "deleted" } : item))),
|
|
|
|
|
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
|
|
|
|
|
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
|
|
|
|
onMediaState: media.setMediaState,
|
2026-05-18 20:15:38 +07:00
|
|
|
onVoiceRecordingUploaded: (recording) => {
|
|
|
|
|
const event = new CustomEvent("voice_recording_uploaded", { detail: recording });
|
|
|
|
|
window.dispatchEvent(event);
|
|
|
|
|
},
|
2026-05-16 19:17:34 +07:00
|
|
|
onPcm: handleIncomingPcm,
|
|
|
|
|
});
|
2026-05-14 20:46:21 +07:00
|
|
|
|
2026-05-16 21:08:39 +07:00
|
|
|
const stopStreamingLocal = useCallback(() => {
|
|
|
|
|
setIsStreaming(false);
|
2026-05-30 16:20:41 +07:00
|
|
|
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; }
|
2026-05-16 21:08:39 +07:00
|
|
|
setLevels(Array.from({ length: 32 }, () => 0.04));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const startStreamingLocal = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
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);
|
2026-05-30 16:20:41 +07:00
|
|
|
for (let i = 0; i < inputData.length; i++) pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
2026-05-16 21:08:39 +07:00
|
|
|
socket.socketRef.current.send(pcmData.buffer);
|
|
|
|
|
let sum = 0;
|
|
|
|
|
for (let i = 0; i < inputData.length; i++) sum += Math.abs(inputData[i]);
|
|
|
|
|
const average = inputData.length ? sum / inputData.length : 0;
|
|
|
|
|
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
|
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Microphone access failed:", err);
|
|
|
|
|
setIsStreaming(false);
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}, [socket.socketRef]);
|
|
|
|
|
|
|
|
|
|
const toggleStreaming = useCallback(async () => {
|
2026-05-30 16:31:27 +07:00
|
|
|
if (isStreaming) { stopStreamingLocal(); patchUIState({ isStreaming: false }); }
|
|
|
|
|
else { await startStreamingLocal(); patchUIState({ isStreaming: true }); }
|
2026-05-16 21:08:39 +07:00
|
|
|
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
|
|
|
|
|
|
2026-05-30 16:20:41 +07:00
|
|
|
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]);
|
2026-05-14 20:46:21 +07:00
|
|
|
|
2026-05-16 19:17:34 +07:00
|
|
|
const toggleListening = useCallback(async () => {
|
2026-05-30 16:31:27 +07:00
|
|
|
if (isListening) { await audioContextListenRef.current?.suspend(); userTimelinesRef.current.clear(); setIsListening(false); patchUIState({ isListening: false }); return; }
|
2026-05-16 19:17:34 +07:00
|
|
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
2026-05-16 21:08:39 +07:00
|
|
|
audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
|
|
|
|
await audioContextListenRef.current.resume();
|
2026-05-16 19:17:34 +07:00
|
|
|
setIsListening(true);
|
2026-05-30 16:31:27 +07:00
|
|
|
patchUIState({ isListening: true });
|
2026-05-16 19:17:34 +07:00
|
|
|
}, [isListening, patchUIState]);
|
|
|
|
|
|
2026-05-30 16:31:27 +07:00
|
|
|
const tabs = useMemo(() => ["live", "messages", "analytics"] as DashboardTab[], []);
|
2026-05-14 21:01:57 +07:00
|
|
|
|
2026-05-14 20:24:41 +07:00
|
|
|
return (
|
2026-05-16 19:17:34 +07:00
|
|
|
<DashboardLayout
|
|
|
|
|
activeTab={activeTab}
|
|
|
|
|
wsStatus={socket.status}
|
|
|
|
|
voiceStatus={voice.voiceStatus}
|
|
|
|
|
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
|
|
|
|
>
|
|
|
|
|
<div className="md:hidden">
|
|
|
|
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
2026-05-30 16:20:41 +07:00
|
|
|
<div className="mb-4 grid grid-cols-4 gap-1.5 rounded-2xl bg-muted p-1">
|
2026-05-16 19:17:34 +07:00
|
|
|
{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}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</Tabs>
|
2026-05-14 20:46:21 +07:00
|
|
|
</div>
|
2026-05-16 19:17:34 +07:00
|
|
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
2026-05-30 16:20:41 +07:00
|
|
|
<TabsContent value="live">
|
2026-05-17 00:20:29 +07:00
|
|
|
{!isAuthenticated ? (
|
|
|
|
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
|
|
|
|
) : (
|
2026-05-30 16:20:41 +07:00
|
|
|
<LivePanel
|
2026-05-17 00:20:29 +07:00
|
|
|
guilds={voice.guilds}
|
2026-05-30 16:20:41 +07:00
|
|
|
voiceChannels={voice.voiceChannels}
|
2026-05-17 00:20:29 +07:00
|
|
|
selectedGuild={selectedVoiceGuild}
|
|
|
|
|
selectedChannel={selectedVoiceChannel}
|
|
|
|
|
status={voice.voiceStatus}
|
2026-05-30 16:20:41 +07:00
|
|
|
voiceLoading={voice.loading}
|
2026-05-17 00:20:29 +07:00
|
|
|
activeSpeakers={activeSpeakers}
|
|
|
|
|
levels={levels}
|
|
|
|
|
isListening={isListening}
|
|
|
|
|
isStreaming={isStreaming}
|
2026-05-30 16:20:41 +07:00
|
|
|
mediaState={media.mediaState}
|
|
|
|
|
mediaLoading={media.loading}
|
2026-05-17 00:20:29 +07:00
|
|
|
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
|
|
|
|
|
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
|
|
|
|
|
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
|
|
|
|
|
onDisconnect={() => voice.leaveVoice()}
|
|
|
|
|
onListenToggle={toggleListening}
|
|
|
|
|
onStreamingToggle={toggleStreaming}
|
|
|
|
|
onQueueMusic={(source) => media.enqueue(source, "music")}
|
|
|
|
|
onStartScreen={(source) => media.enqueue(source, "screen")}
|
|
|
|
|
onSkip={media.skip}
|
|
|
|
|
onStop={media.stop}
|
2026-05-17 04:52:20 +07:00
|
|
|
onVolumeChange={media.setVolume}
|
2026-05-17 00:20:29 +07:00
|
|
|
/>
|
|
|
|
|
)}
|
2026-05-16 19:17:34 +07:00
|
|
|
</TabsContent>
|
|
|
|
|
<TabsContent value="messages">
|
|
|
|
|
<MessagesPanel
|
|
|
|
|
guilds={voice.guilds}
|
|
|
|
|
channels={voice.textChannels}
|
|
|
|
|
selectedGuild={selectedTextGuild}
|
|
|
|
|
selectedChannel={selectedTextChannel}
|
|
|
|
|
messages={messages.messages}
|
|
|
|
|
onGuildChange={(guildId) => patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })}
|
|
|
|
|
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
|
|
|
|
|
onReanalyze={messages.reanalyze}
|
|
|
|
|
/>
|
|
|
|
|
</TabsContent>
|
2026-05-29 19:37:08 +07:00
|
|
|
<TabsContent value="analytics">
|
|
|
|
|
<AnalyticsPanel
|
|
|
|
|
guilds={voice.guilds}
|
|
|
|
|
channels={voice.textChannels}
|
|
|
|
|
selectedGuild={selectedTextGuild}
|
|
|
|
|
selectedChannel={selectedTextChannel}
|
|
|
|
|
onGuildChange={(guildId) => patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })}
|
|
|
|
|
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
|
|
|
|
|
/>
|
|
|
|
|
</TabsContent>
|
2026-05-16 19:17:34 +07:00
|
|
|
</Tabs>
|
|
|
|
|
</DashboardLayout>
|
2026-05-14 20:24:41 +07:00
|
|
|
);
|
|
|
|
|
}
|