diff --git a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx index 8ed32cf..b26b4ec 100644 --- a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx +++ b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx @@ -1,12 +1,13 @@ // ─── Recordings Sub-Panel ── -import { Download, Mic, Pause, Play, Trash2 } from "lucide-react"; +import { Download, Mic, Trash2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { VoiceRecording } 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"; +import { WaveformPlayer } from "./WaveformPlayer"; export function RecordingsSubPanel() { const [recordings, setRecordings] = useState([]); @@ -16,8 +17,6 @@ export function RecordingsSubPanel() { const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [deletingIds, setDeletingIds] = useState>(new Set()); - const [activePlayingId, setActivePlayingId] = useState(null); - const audioRefs = useRef>(new Map()); const loadRecordings = useCallback(async ( opts?: { signal?: AbortSignal }, @@ -82,22 +81,6 @@ export function RecordingsSubPanel() { } }, []); - const handleTogglePlay = useCallback((id: string) => { - setActivePlayingId((prev) => { - if (prev === id) { - // Pause current - audioRefs.current.get(id)?.pause(); - return null; - } - // Pause any previously playing - if (prev) audioRefs.current.get(prev)?.pause(); - // Play new - const audio = audioRefs.current.get(id); - if (audio) audio.play().catch(() => {}); - return id; - }); - }, []); - if (loading) { return (
@@ -188,25 +171,6 @@ export function RecordingsSubPanel() { {rec.download_url && ( <> - -
+ {rec.download_url && ( +
+ +
+ )} ))} {hasMore && (
diff --git a/services/frontend/src/features/live/components/WaveformPlayer.tsx b/services/frontend/src/features/live/components/WaveformPlayer.tsx new file mode 100644 index 0000000..88dddd4 --- /dev/null +++ b/services/frontend/src/features/live/components/WaveformPlayer.tsx @@ -0,0 +1,253 @@ +// ─── Waveform Player — audio visualizer with seekable waveform bars ────────── + +import { Pause, Play } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { createLogger } from "../../../shared/lib/logger"; + +const logger = createLogger("waveform-player"); + +const BAR_COUNT = 64; +const SAMPLE_RATE = 24000; + +interface WaveformPlayerProps { + downloadUrl: string; + filename: string; +} + +export function WaveformPlayer({ downloadUrl, filename }: WaveformPlayerProps) { + const [playing, setPlaying] = useState(false); + const [peaks, setPeaks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const canvasRef = useRef(null); + const containerRef = useRef(null); + const audioContextRef = useRef(null); + const sourceRef = useRef(null); + const startTimeRef = useRef(0); + const startOffsetRef = useRef(0); + const rafRef = useRef(0); + const decodedRef = useRef(null); + const durationRef = useRef(0); + + // Decode audio on mount + useEffect(() => { + let cancelled = false; + const ctx = new AudioContext(); + audioContextRef.current = ctx; + + fetch(downloadUrl) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.arrayBuffer(); + }) + .then((buf) => ctx.decodeAudioData(buf)) + .then((audioBuffer) => { + if (cancelled) return; + decodedRef.current = audioBuffer; + durationRef.current = audioBuffer.duration; + + // Compute waveform peaks + const channel = audioBuffer.getChannelData(0); + const samplesPerBar = Math.floor(channel.length / BAR_COUNT); + const peakValues: number[] = []; + for (let i = 0; i < BAR_COUNT; i++) { + let max = 0; + const start = i * samplesPerBar; + const end = Math.min(start + samplesPerBar, channel.length); + for (let j = start; j < end; j++) { + const abs = Math.abs(channel[j]); + if (abs > max) max = abs; + } + // Clamp so silent sections still show a tiny bar + peakValues.push(Math.max(0.01, max)); + } + setPeaks(peakValues); + setLoading(false); + }) + .catch((err) => { + if (cancelled) return; + const msg = err instanceof Error ? err.message : String(err); + logger.error("Failed to decode audio", { error: msg }); + setError(msg); + setLoading(false); + }); + + return () => { + cancelled = true; + ctx.close(); + }; + }, [downloadUrl]); + + // Draw waveform on canvas whenever peaks change or while playing + const drawWaveform = useCallback( + (progress = 0) => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + const dpr = window.devicePixelRatio || 1; + const rect = container.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = 64 * dpr; + canvas.style.height = "64px"; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, rect.width, 64); + + if (peaks.length === 0) return; + + const barWidth = rect.width / peaks.length; + const barGap = Math.max(1, barWidth * 0.15); + const barActualWidth = barWidth - barGap; + const progressPixel = rect.width * progress; + + for (let i = 0; i < peaks.length; i++) { + const x = i * barWidth; + const height = Math.max(2, peaks[i] * 50); + const y = 32 - height / 2; + + // Color: played vs unplayed + const isPlayed = x + barWidth <= progressPixel; + ctx.fillStyle = isPlayed ? "#23a1eb" : "#334155"; + ctx.fillRect(x, y, barActualWidth, height); + } + }, + [peaks], + ); + + // Initial draw when peaks change + useEffect(() => { + drawWaveform(); + }, [drawWaveform]); + + // Animation loop while playing + useEffect(() => { + if (!playing || !decodedRef.current) return; + + const tick = () => { + const elapsed = + audioContextRef.current!.currentTime - startTimeRef.current; + const progress = (elapsed + startOffsetRef.current) / durationRef.current; + drawWaveform(Math.min(1, Math.max(0, progress))); + + if (progress >= 1) { + setPlaying(false); + return; + } + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + + return () => cancelAnimationFrame(rafRef.current); + }, [playing, drawWaveform]); + + const handleTogglePlay = useCallback(() => { + const ctx = audioContextRef.current; + const buffer = decodedRef.current; + if (!ctx || !buffer) return; + + if (playing) { + // Pause + if (sourceRef.current) { + startOffsetRef.current += ctx.currentTime - startTimeRef.current; + sourceRef.current.stop(); + sourceRef.current.disconnect(); + sourceRef.current = null; + } + setPlaying(false); + return; + } + + // Resume / start + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.connect(ctx.destination); + source.start(0, startOffsetRef.current); + startTimeRef.current = ctx.currentTime; + sourceRef.current = source; + setPlaying(true); + + source.onended = () => { + if (sourceRef.current === source) { + setPlaying(false); + sourceRef.current = null; + } + }; + }, [playing]); + + const handleSeek = useCallback( + (e: React.MouseEvent) => { + if (!decodedRef.current) return; + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const progress = Math.max(0, Math.min(1, x / rect.width)); + const offset = progress * durationRef.current; + + const ctx = audioContextRef.current; + if (ctx && sourceRef.current) { + sourceRef.current.stop(); + sourceRef.current.disconnect(); + } + + startOffsetRef.current = offset; + startTimeRef.current = ctx?.currentTime ?? 0; + drawWaveform(progress); + + if (playing && ctx) { + const buffer = decodedRef.current; + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.connect(ctx.destination); + source.start(0, offset); + startTimeRef.current = ctx.currentTime; + sourceRef.current = source; + source.onended = () => { + if (sourceRef.current === source) { + setPlaying(false); + sourceRef.current = null; + } + }; + } + }, + [playing, drawWaveform], + ); + + if (loading) { + return ( +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (peaks.length === 0) return null; + + return ( +
+ +
+ +
+
+ ); +} diff --git a/services/frontend/src/features/live/components/index.ts b/services/frontend/src/features/live/components/index.ts index 1545061..7563287 100644 --- a/services/frontend/src/features/live/components/index.ts +++ b/services/frontend/src/features/live/components/index.ts @@ -8,3 +8,4 @@ export { RecordingsSubPanel } from "./RecordingsSubPanel"; export { ScreenSubPanel } from "./ScreenSubPanel"; export { MicLevelMeter } from "./MicLevelMeter"; export { VoiceConnectionCard } from "./VoiceConnectionCard"; +export { WaveformPlayer } from "./WaveformPlayer";