chore(auto): task completed - unknown
This commit is contained in:
@@ -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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export class RecordingsService {
|
|||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteById(id: string): Promise<void> {
|
||||||
|
const db = getDatabase();
|
||||||
|
await db.execute(sql`DELETE FROM voice_recordings WHERE id = ${id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const recordingsService = new RecordingsService();
|
export const recordingsService = new RecordingsService();
|
||||||
|
|||||||
@@ -48,10 +48,43 @@ export default function App() {
|
|||||||
[monitorGuildId, voice.guilds],
|
[monitorGuildId, voice.guilds],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update speaker list from incremental voice_active_user events
|
||||||
|
const updateSpeakerList = (
|
||||||
|
prev: ActiveSpeaker[],
|
||||||
|
data: Partial<ActiveSpeaker> & { 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({
|
const socket = useDashboardSocket({
|
||||||
onVoicePcmData: (d) =>
|
onVoicePcmData: (d) =>
|
||||||
audio.handleIncomingPcm(d as { userId: string; pcm: string }),
|
audio.handleIncomingPcm(d as { userId: string; pcm: string }),
|
||||||
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
|
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
|
||||||
|
onVoiceActiveUser: (data) =>
|
||||||
|
setActiveSpeakers((prev) =>
|
||||||
|
updateSpeakerList(
|
||||||
|
prev,
|
||||||
|
data as Partial<ActiveSpeaker> & {
|
||||||
|
userId?: string;
|
||||||
|
id?: string;
|
||||||
|
speaking: boolean;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onVoiceRecordingStarted: () =>
|
||||||
|
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||||
|
onVoiceRecordingStopped: () =>
|
||||||
|
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||||
onMessageCreated: (m) =>
|
onMessageCreated: (m) =>
|
||||||
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||||
onMessageUpdated: (m) => {
|
onMessageUpdated: (m) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ActiveSpeaker } from "../../../shared/api/client";
|
import type { ActiveSpeaker } from "../../../shared/api/client";
|
||||||
import { Skeleton } from "../../../shared/ui";
|
|
||||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||||
|
|
||||||
interface ActiveSpeakersProps {
|
interface ActiveSpeakersProps {
|
||||||
@@ -14,7 +13,6 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{speakers.map((s) => {
|
{speakers.map((s) => {
|
||||||
// BUG 4 FIX: stable key — no index fallback
|
|
||||||
const key = s.userId ?? s.id ?? `speaker-${s.username}`;
|
const key = s.userId ?? s.id ?? `speaker-${s.username}`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -28,8 +26,19 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
|||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||||
<div className="text-xs font-medium text-emerald-700">
|
<div className="flex items-center gap-1.5">
|
||||||
Speaking
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
s.speaking ? "bg-emerald-500" : "bg-muted-foreground/40"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
s.speaking ? "text-emerald-600" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.speaking ? "Speaking" : "Silent"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,22 +47,3 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActiveSpeakersSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3"
|
|
||||||
>
|
|
||||||
<Skeleton className="h-8 w-8 rounded-full" />
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-3 w-16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ interface AudioVisualizerProps {
|
|||||||
|
|
||||||
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -13,15 +30,16 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const width = canvas.width;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const height = canvas.height;
|
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 barWidth = width / levels.length;
|
||||||
const maxBarHeight = height * 0.85;
|
const maxBarHeight = height * 0.85;
|
||||||
|
|
||||||
// IMPHNEN blue gradient
|
|
||||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||||
gradient.addColorStop(0, "#23a1eb");
|
gradient.addColorStop(0, "#23a1eb");
|
||||||
gradient.addColorStop(1, "#3eb0f2");
|
gradient.addColorStop(1, "#3eb0f2");
|
||||||
@@ -34,7 +52,6 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
|||||||
|
|
||||||
ctx.fillStyle = gradient;
|
ctx.fillStyle = gradient;
|
||||||
|
|
||||||
// More rounded bar
|
|
||||||
const radius = barWidth * 0.4;
|
const radius = barWidth * 0.4;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x + radius, y);
|
ctx.moveTo(x + radius, y);
|
||||||
@@ -49,11 +66,11 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
|||||||
}, [levels]);
|
}, [levels]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div ref={containerRef} className="relative w-full">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={512}
|
width={0}
|
||||||
height={128}
|
height={0}
|
||||||
className="w-full rounded-lg bg-primary/5"
|
className="w-full rounded-lg bg-primary/5"
|
||||||
style={{ height: "128px" }}
|
style={{ height: "128px" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Music2, SkipForward, Square, Volume2 } from "lucide-react";
|
import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Button, Input } from "../../../shared/ui";
|
import { Button, Input } from "../../../shared/ui";
|
||||||
|
|
||||||
interface MusicSubPanelProps {
|
interface MusicSubPanelProps {
|
||||||
@@ -24,17 +24,39 @@ export function MusicSubPanel({
|
|||||||
? Math.max(0, Math.min(1, volume))
|
? Math.max(0, Math.min(1, volume))
|
||||||
: 1;
|
: 1;
|
||||||
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const prevVolumeRef = useRef(safeVolume);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Debounced volume — poll every 200ms instead of instant send to avoid flood
|
// Proper debounce: setTimeout instead of setInterval polling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
const normalized = draftVolume / 100;
|
const normalized = draftVolume / 100;
|
||||||
if (Math.abs(normalized - safeVolume) >= 0.001)
|
if (Math.abs(normalized - safeVolume) >= 0.001)
|
||||||
onVolumeChange(normalized);
|
onVolumeChange(normalized);
|
||||||
}, 200);
|
}, 200);
|
||||||
return () => clearInterval(id);
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
}, [draftVolume, safeVolume, onVolumeChange]);
|
}, [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 submit = () => {
|
||||||
const t = source.trim();
|
const t = source.trim();
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
@@ -51,14 +73,23 @@ export function MusicSubPanel({
|
|||||||
placeholder="YouTube URL, Spotify track, or search terms"
|
placeholder="YouTube URL, Spotify track, or search terms"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Volume2 className="h-4 w-4 shrink-0 text-primary" />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleMute}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
value={draftVolume}
|
value={draftVolume}
|
||||||
onChange={(e) => 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"
|
className="h-2 w-full cursor-pointer accent-primary"
|
||||||
/>
|
/>
|
||||||
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// ─── Recordings Sub-Panel ──
|
// ─── Recordings Sub-Panel ──
|
||||||
|
|
||||||
import { Download, Mic } from "lucide-react";
|
import { Download, Mic, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { VoiceRecording } from "../../../shared/api/client";
|
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 { formatBytes, formatDate } from "../../../shared/lib/utils";
|
||||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||||
@@ -12,29 +12,50 @@ export function RecordingsSubPanel() {
|
|||||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deletingIds, setDeletingIds] = useState<Set<string>>(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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const ab = new AbortController();
|
||||||
async function loadRecordings() {
|
loadRecordings({ signal: ab.signal });
|
||||||
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 handler = () => loadRecordings();
|
const handler = () => loadRecordings();
|
||||||
window.addEventListener("voice_recording_uploaded", handler);
|
window.addEventListener("voice_recording_uploaded", handler);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
ab.abort();
|
||||||
window.removeEventListener("voice_recording_uploaded", handler);
|
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) {
|
if (loading) {
|
||||||
@@ -64,7 +85,7 @@ export function RecordingsSubPanel() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => loadRecordings()}
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
@@ -105,6 +126,15 @@ export function RecordingsSubPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={deletingIds.has(rec.id)}
|
||||||
|
onClick={() => handleDelete(rec.id)}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
rec.upload_status === "uploaded"
|
rec.upload_status === "uploaded"
|
||||||
|
|||||||
@@ -258,6 +258,10 @@ export function listRecordings(limit = 50): Promise<VoiceRecording[]> {
|
|||||||
return request<VoiceRecording[]>(`/api/recordings?limit=${limit}`);
|
return request<VoiceRecording[]>(`/api/recordings?limit=${limit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteRecording(id: string): Promise<void> {
|
||||||
|
return request<void>(`/api/recordings/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function login(password: string): Promise<{ ok: boolean }> {
|
export function login(password: string): Promise<{ ok: boolean }> {
|
||||||
|
|||||||
@@ -97,10 +97,13 @@ export function useAudioTransmit(socketRef: {
|
|||||||
for (let i = 0; i < inputData.length; i++)
|
for (let i = 0; i < inputData.length; i++)
|
||||||
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||||
|
|
||||||
// 6b: Replace string-concatenation loop with single call
|
// 6b: Safe loop instead of spread operator to avoid call-stack overflow
|
||||||
// 1024 samples → 2048 bytes — well within call-stack limits
|
|
||||||
const bytes = new Uint8Array(pcmData.buffer);
|
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(
|
socketRef.current.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user