diff --git a/services/backend/src/modules/recordings/recordings.routes.ts b/services/backend/src/modules/recordings/recordings.routes.ts index e06211b..7c1525d 100644 --- a/services/backend/src/modules/recordings/recordings.routes.ts +++ b/services/backend/src/modules/recordings/recordings.routes.ts @@ -25,5 +25,15 @@ export function createRecordingsRouter(): Router { }), ); + // DELETE /api/recordings/:id + router.delete( + "/recordings/:id", + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + await recordingsService.deleteById(id); + res.json({ ok: true }); + }), + ); + return router; } diff --git a/services/backend/src/modules/recordings/recordings.service.ts b/services/backend/src/modules/recordings/recordings.service.ts index 27e6b07..2e217e2 100644 --- a/services/backend/src/modules/recordings/recordings.service.ts +++ b/services/backend/src/modules/recordings/recordings.service.ts @@ -42,6 +42,11 @@ export class RecordingsService { return rows; } + + async deleteById(id: string): Promise { + const db = getDatabase(); + await db.execute(sql`DELETE FROM voice_recordings WHERE id = ${id}`); + } } export const recordingsService = new RecordingsService(); diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 264372b..6b37c1e 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -48,10 +48,43 @@ export default function App() { [monitorGuildId, voice.guilds], ); + // Update speaker list from incremental voice_active_user events + const updateSpeakerList = ( + prev: ActiveSpeaker[], + data: Partial & { userId?: string; id?: string; speaking: boolean }, + ): ActiveSpeaker[] => { + const key = data.userId ?? data.id; + if (!key) return prev; + const idx = prev.findIndex( + (s) => (s.userId ?? s.id) === key, + ); + if (idx >= 0) { + const next = [...prev]; + next[idx] = { ...next[idx], ...data }; + return next; + } + return [...prev, data as ActiveSpeaker]; + }; + const socket = useDashboardSocket({ onVoicePcmData: (d) => audio.handleIncomingPcm(d as { userId: string; pcm: string }), onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]), + onVoiceActiveUser: (data) => + setActiveSpeakers((prev) => + updateSpeakerList( + prev, + data as Partial & { + userId?: string; + id?: string; + speaking: boolean; + }, + ), + ), + onVoiceRecordingStarted: () => + window.dispatchEvent(new CustomEvent("voice_recording_uploaded")), + onVoiceRecordingStopped: () => + window.dispatchEvent(new CustomEvent("voice_recording_uploaded")), onMessageCreated: (m) => messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])), onMessageUpdated: (m) => { diff --git a/services/frontend/src/features/live/components/ActiveSpeakers.tsx b/services/frontend/src/features/live/components/ActiveSpeakers.tsx index fd70ee3..1ba1002 100644 --- a/services/frontend/src/features/live/components/ActiveSpeakers.tsx +++ b/services/frontend/src/features/live/components/ActiveSpeakers.tsx @@ -1,5 +1,4 @@ import type { ActiveSpeaker } from "../../../shared/api/client"; -import { Skeleton } from "../../../shared/ui"; import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; interface ActiveSpeakersProps { @@ -14,7 +13,6 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) { return (
{speakers.map((s) => { - // BUG 4 FIX: stable key — no index fallback const key = s.userId ?? s.id ?? `speaker-${s.username}`; return (
{s.username}
-
- Speaking +
+ + + {s.speaking ? "Speaking" : "Silent"} +
@@ -38,22 +47,3 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
); } - -function ActiveSpeakersSkeleton() { - return ( -
- {[1, 2, 3].map((i) => ( -
- -
- - -
-
- ))} -
- ); -} diff --git a/services/frontend/src/features/live/components/AudioVisualizer.tsx b/services/frontend/src/features/live/components/AudioVisualizer.tsx index e8bd971..b531598 100644 --- a/services/frontend/src/features/live/components/AudioVisualizer.tsx +++ b/services/frontend/src/features/live/components/AudioVisualizer.tsx @@ -6,6 +6,23 @@ interface AudioVisualizerProps { export function AudioVisualizer({ levels }: AudioVisualizerProps) { const canvasRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const ro = new ResizeObserver(() => { + const rect = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = 128 * dpr; + canvas.style.height = "128px"; + }); + ro.observe(container); + return () => ro.disconnect(); + }, []); useEffect(() => { const canvas = canvasRef.current; @@ -13,15 +30,16 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) { const ctx = canvas.getContext("2d"); if (!ctx) return; - const width = canvas.width; - const height = canvas.height; + const dpr = window.devicePixelRatio || 1; + const width = canvas.width / dpr; + const height = canvas.height / dpr; - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.scale(dpr, dpr); const barWidth = width / levels.length; const maxBarHeight = height * 0.85; - // IMPHNEN blue gradient const gradient = ctx.createLinearGradient(0, 0, 0, height); gradient.addColorStop(0, "#23a1eb"); gradient.addColorStop(1, "#3eb0f2"); @@ -34,7 +52,6 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) { ctx.fillStyle = gradient; - // More rounded bar const radius = barWidth * 0.4; ctx.beginPath(); ctx.moveTo(x + radius, y); @@ -49,11 +66,11 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) { }, [levels]); return ( -
+
diff --git a/services/frontend/src/features/live/components/MusicSubPanel.tsx b/services/frontend/src/features/live/components/MusicSubPanel.tsx index e6b9bbb..ab6bd35 100644 --- a/services/frontend/src/features/live/components/MusicSubPanel.tsx +++ b/services/frontend/src/features/live/components/MusicSubPanel.tsx @@ -1,5 +1,5 @@ -import { Music2, SkipForward, Square, Volume2 } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button, Input } from "../../../shared/ui"; interface MusicSubPanelProps { @@ -24,17 +24,39 @@ export function MusicSubPanel({ ? Math.max(0, Math.min(1, volume)) : 1; const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100)); + const [muted, setMuted] = useState(false); + const prevVolumeRef = useRef(safeVolume); + const debounceRef = useRef | null>(null); - // Debounced volume — poll every 200ms instead of instant send to avoid flood + // Proper debounce: setTimeout instead of setInterval polling useEffect(() => { - const id = setInterval(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { const normalized = draftVolume / 100; if (Math.abs(normalized - safeVolume) >= 0.001) onVolumeChange(normalized); }, 200); - return () => clearInterval(id); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; }, [draftVolume, safeVolume, onVolumeChange]); + const handleMute = useCallback(() => { + if (muted) { + // Unmute: restore previous volume + const restore = prevVolumeRef.current; + setDraftVolume(Math.round(restore * 100)); + onVolumeChange(restore); + setMuted(false); + } else { + // Mute: save current, set to 0 + prevVolumeRef.current = safeVolume; + setDraftVolume(0); + onVolumeChange(0); + setMuted(true); + } + }, [muted, safeVolume, onVolumeChange]); + const submit = () => { const t = source.trim(); if (!t) return; @@ -51,14 +73,23 @@ export function MusicSubPanel({ placeholder="YouTube URL, Spotify track, or search terms" />
- + setDraftVolume(Number(e.target.value))} + onChange={(e) => { + setDraftVolume(Number(e.target.value)); + if (muted) setMuted(false); + }} className="h-2 w-full cursor-pointer accent-primary" /> diff --git a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx index 75bd24b..1adefd0 100644 --- a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx +++ b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx @@ -1,9 +1,9 @@ // ─── Recordings Sub-Panel ── -import { Download, Mic } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Download, Mic, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import type { VoiceRecording } from "../../../shared/api/client"; -import { listRecordings } from "../../../shared/api/client"; +import { deleteRecording, listRecordings } from "../../../shared/api/client"; import { formatBytes, formatDate } from "../../../shared/lib/utils"; import { Badge, Button, Skeleton } from "../../../shared/ui"; import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; @@ -12,29 +12,50 @@ export function RecordingsSubPanel() { const [recordings, setRecordings] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [deletingIds, setDeletingIds] = useState>(new Set()); + + const loadRecordings = useCallback(async ( + opts?: { signal?: AbortSignal }, + ) => { + try { + setLoading(true); + setError(null); + const data = await listRecordings(); + if (!opts?.signal?.aborted) setRecordings(data); + } catch (err) { + if (!opts?.signal?.aborted) + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!opts?.signal?.aborted) setLoading(false); + } + }, []); useEffect(() => { - let cancelled = false; - async function loadRecordings() { - try { - setLoading(true); - setError(null); - const data = await listRecordings(); - if (!cancelled) setRecordings(data); - } catch (err) { - if (!cancelled) - setError(err instanceof Error ? err.message : String(err)); - } finally { - if (!cancelled) setLoading(false); - } - } - loadRecordings(); + const ab = new AbortController(); + loadRecordings({ signal: ab.signal }); const handler = () => loadRecordings(); window.addEventListener("voice_recording_uploaded", handler); return () => { - cancelled = true; + ab.abort(); window.removeEventListener("voice_recording_uploaded", handler); }; + }, [loadRecordings]); + + const handleDelete = useCallback(async (id: string) => { + if (!confirm("Delete this recording?")) return; + setDeletingIds((prev) => new Set(prev).add(id)); + try { + await deleteRecording(id); + setRecordings((prev) => prev.filter((r) => r.id !== id)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setDeletingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } }, []); if (loading) { @@ -64,7 +85,7 @@ export function RecordingsSubPanel() { @@ -105,6 +126,15 @@ export function RecordingsSubPanel() { )}
+ { return request(`/api/recordings?limit=${limit}`); } +export function deleteRecording(id: string): Promise { + return request(`/api/recordings/${id}`, { method: "DELETE" }); +} + // ─── Auth ──────────────────────────────────────────────────────────────────── export function login(password: string): Promise<{ ok: boolean }> { diff --git a/services/frontend/src/shared/hooks/useAudioTransmit.ts b/services/frontend/src/shared/hooks/useAudioTransmit.ts index 56a3c2c..f540b89 100644 --- a/services/frontend/src/shared/hooks/useAudioTransmit.ts +++ b/services/frontend/src/shared/hooks/useAudioTransmit.ts @@ -97,10 +97,13 @@ export function useAudioTransmit(socketRef: { for (let i = 0; i < inputData.length; i++) pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767; - // 6b: Replace string-concatenation loop with single call - // 1024 samples → 2048 bytes — well within call-stack limits + // 6b: Safe loop instead of spread operator to avoid call-stack overflow const bytes = new Uint8Array(pcmData.buffer); - const base64 = btoa(String.fromCharCode(...bytes)); + let str = ''; + for (let i = 0; i < bytes.length; i++) { + str += String.fromCharCode(bytes[i]); + } + const base64 = btoa(str); socketRef.current.send( JSON.stringify({