diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index b299964..7a4655c 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -153,6 +153,7 @@ export default function RecordingsPage() { setPlayingId(null)} /> diff --git a/services/frontend/src/app/(dashboard)/voice/page.tsx b/services/frontend/src/app/(dashboard)/voice/page.tsx index 7306154..152cacf 100644 --- a/services/frontend/src/app/(dashboard)/voice/page.tsx +++ b/services/frontend/src/app/(dashboard)/voice/page.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { SubNav } from "@/components/layout/sub-nav"; import { VoiceActivityTimeline } from "@/components/voice/activity-timeline"; import { VoiceConnectionCard } from "@/components/voice/connection-card"; +import { ListenControl } from "@/components/voice/listen-control"; import { MicControl } from "@/components/voice/mic-control"; import { SpeakerWaveform } from "@/components/voice/speaker-waveform"; import { @@ -13,6 +14,7 @@ import { useVoiceChannels, useVoiceConnect, useVoiceDisconnect, + useVoiceListen, useVoiceStatus, } from "@/hooks"; import { useWebSocket } from "@/lib/ws/context"; @@ -29,9 +31,11 @@ export default function VoicePage() { const connectMut = useVoiceConnect(); const disconnectMut = useVoiceDisconnect(); const micMut = useMicTransmit(ws); + const listen = useVoiceListen(ws); const [selectedChannel, setSelectedChannel] = useState(""); const [micActive, setMicActive] = useState(false); const [volume, setVolume] = useState(75); + const [listenVolume, setListenVolume] = useState(75); const [tab, setTab] = useState("connection"); useEffect(() => { @@ -111,6 +115,7 @@ export default function VoicePage() { setMicActive(false); void micMut.mutateAsync(false).catch(() => {}); } + if (listen.active) listen.toggle(false); disconnectMut.mutate(undefined); }} connecting={connectMut.isPending} @@ -119,13 +124,27 @@ export default function VoicePage() { {tab === "connection" && (
- +
+ listen.toggle(on)} + volume={listenVolume} + onVolumeChange={(v) => { + setListenVolume(v); + listen.setVolume(v); + }} + /> + +
)} diff --git a/services/frontend/src/components/recordings/recording-card.tsx b/services/frontend/src/components/recordings/recording-card.tsx index 15f493a..b3e435e 100644 --- a/services/frontend/src/components/recordings/recording-card.tsx +++ b/services/frontend/src/components/recordings/recording-card.tsx @@ -1,6 +1,7 @@ "use client"; -import { Download, Play } from "lucide-react"; +import { useState } from "react"; +import { Download, Loader2, Play } from "lucide-react"; import { GlassCard } from "@/components/glass/card"; import type { VoiceRecording } from "@/lib/types"; @@ -10,10 +11,36 @@ interface RecordingCardProps { } export function RecordingCard({ recording, onPlay }: RecordingCardProps) { + const [downloading, setDownloading] = useState(false); const durationStr = recording.duration_bytes ? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}` : "--:--"; + // Fetch the file (CORS is open on the uploader) → blob → force download with + // the real filename. Falls back to opening the URL in a new tab. + const handleDownload = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!recording.download_url || downloading) return; + setDownloading(true); + try { + const res = await fetch(recording.download_url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const objUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objUrl; + a.download = recording.filename ?? `recording-${recording.id}.mp3`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(objUrl), 30_000); + } catch { + window.open(recording.download_url, "_blank", "noopener,noreferrer"); + } finally { + setDownloading(false); + } + }; + return ( onPlay(recording.id)}>
@@ -50,9 +77,19 @@ export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
e.stopPropagation()}> {recording.download_url && ( - - - + )}
diff --git a/services/frontend/src/components/recordings/recording-player.tsx b/services/frontend/src/components/recordings/recording-player.tsx index 643cc76..3a246e9 100644 --- a/services/frontend/src/components/recordings/recording-player.tsx +++ b/services/frontend/src/components/recordings/recording-player.tsx @@ -1,31 +1,57 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { GlassPanel } from "@/components/glass/panel"; import { X } from "lucide-react"; interface RecordingPlayerProps { url?: string; + filename?: string; onClose: () => void; } -export function RecordingPlayer({ url, onClose }: RecordingPlayerProps) { +export function RecordingPlayer({ url, filename, onClose }: RecordingPlayerProps) { const audioRef = useRef(null); + const [error, setError] = useState(false); useEffect(() => { - if (url && audioRef.current) { - audioRef.current?.play().catch(() => {}); - } + const audio = audioRef.current; + if (!url || !audio) return; + setError(false); + // Fresh element state: reset src, load, then play (the click that opened + // the player counts as a user gesture, so autoplay is allowed). + audio.src = url; + audio.load(); + const p = audio.play(); + if (p) p.catch(() => setError(true)); }, [url]); if (!url) return null; return ( - -