From 67a9e308a9af8f8502d4ba9d9879c4ee2c0d6dba Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 23 Apr 2026 19:20:42 -0700 Subject: [PATCH] feat(captures): scrubbable WaveSurfer player for capture detail view Replace the placeholder fake-waveform + play button in CapturesTab's audio card with a real CaptureInlinePlayer (wavesurfer.js). The player renders the actual waveform, lets users scrub through the clip, and shows a proper current/total timestamp pair in place of the duration-only label. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CapturesTab/CaptureInlinePlayer.tsx | 153 ++++++++++++++++++ .../components/CapturesTab/CapturesTab.tsx | 58 +------ 2 files changed, 158 insertions(+), 53 deletions(-) create mode 100644 app/src/components/CapturesTab/CaptureInlinePlayer.tsx diff --git a/app/src/components/CapturesTab/CaptureInlinePlayer.tsx b/app/src/components/CapturesTab/CaptureInlinePlayer.tsx new file mode 100644 index 00000000..82a63b12 --- /dev/null +++ b/app/src/components/CapturesTab/CaptureInlinePlayer.tsx @@ -0,0 +1,153 @@ +import { Loader2, Pause, Play } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import WaveSurfer from 'wavesurfer.js'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils/cn'; +import { debug } from '@/lib/utils/debug'; + +function formatDuration(ms?: number | null): string { + if (!ms || ms < 0) return '0:00'; + const total = Math.round(ms / 1000); + const m = Math.floor(total / 60); + const s = total % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +export function CaptureInlinePlayer({ + audioUrl, + fallbackDurationMs, + className, +}: { + audioUrl: string; + fallbackDurationMs?: number | null; + className?: string; +}) { + const waveformRef = useRef(null); + const wavesurferRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [duration, setDuration] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + const container = waveformRef.current; + if (!container) return; + + const root = document.documentElement; + const cssHsla = (varName: string, alpha: number) => { + const value = getComputedStyle(root).getPropertyValue(varName).trim(); + return value ? `hsl(${value} / ${alpha})` : ''; + }; + + const ws = WaveSurfer.create({ + container, + waveColor: cssHsla('--foreground', 0.25), + progressColor: cssHsla('--accent', 1), + cursorColor: 'transparent', + barWidth: 2, + barRadius: 2, + barGap: 2, + height: 40, + normalize: true, + interact: true, + dragToSeek: { debounceTime: 0 }, + mediaControls: false, + backend: 'WebAudio', + }); + + ws.on('ready', () => { + setDuration(ws.getDuration()); + setIsLoading(false); + setError(null); + }); + ws.on('play', () => setIsPlaying(true)); + ws.on('pause', () => setIsPlaying(false)); + ws.on('finish', () => { + setIsPlaying(false); + setCurrentTime(ws.getDuration()); + }); + ws.on('timeupdate', (t) => setCurrentTime(t)); + ws.on('seeking', (t) => setCurrentTime(t)); + ws.on('error', (err) => { + debug.error('Inline waveform error', err); + setError(err instanceof Error ? err.message : String(err)); + setIsLoading(false); + }); + + wavesurferRef.current = ws; + + return () => { + try { + ws.destroy(); + } catch (err) { + debug.error('Failed to destroy inline waveform', err); + } + wavesurferRef.current = null; + }; + }, []); + + useEffect(() => { + const ws = wavesurferRef.current; + if (!ws) return; + setIsLoading(true); + setError(null); + setCurrentTime(0); + setDuration(0); + setIsPlaying(false); + try { + if (ws.isPlaying()) ws.pause(); + ws.seekTo(0); + } catch (err) { + debug.error('Failed to reset inline waveform before load', err); + } + ws.load(audioUrl).catch((err) => { + debug.error('Inline waveform load failed', err); + setError(err instanceof Error ? err.message : String(err)); + setIsLoading(false); + }); + }, [audioUrl]); + + const handlePlayPause = () => { + const ws = wavesurferRef.current; + if (!ws || isLoading) return; + if (ws.isPlaying()) { + ws.pause(); + } else { + ws.play().catch((err) => { + debug.error('Inline play failed', err); + setError(err instanceof Error ? err.message : String(err)); + }); + } + }; + + const displayMs = + duration > 0 + ? Math.round((isPlaying || currentTime > 0 ? currentTime : duration) * 1000) + : (fallbackDurationMs ?? 0); + + return ( +
+ +
+ + {error ? '—' : formatDuration(displayMs)} + +
+ ); +} diff --git a/app/src/components/CapturesTab/CapturesTab.tsx b/app/src/components/CapturesTab/CapturesTab.tsx index f89b19f6..c6f2ebc8 100644 --- a/app/src/components/CapturesTab/CapturesTab.tsx +++ b/app/src/components/CapturesTab/CapturesTab.tsx @@ -10,7 +10,6 @@ import { FileAudio, Loader2, Mic, - Play, Send, Settings2, Sparkles, @@ -22,6 +21,7 @@ import { import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { CapturePill } from '@/components/CapturePill/CapturePill'; +import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer'; import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -107,31 +107,6 @@ function SourceBadge({ source }: { source: CaptureSource }) { ); } -function FakeWaveform({ seed = 1, className }: { seed?: number; className?: string }) { - const bars = useMemo(() => { - return Array.from({ length: 72 }).map((_, i) => { - const h = - 28 + - Math.sin(i * 0.35 + seed) * 22 + - Math.cos(i * 0.81 + seed * 2) * 14 + - Math.sin(i * 1.7 + seed * 3) * 8; - return Math.max(6, Math.min(96, h)); - }); - }, [seed]); - - return ( -
- {bars.map((h, i) => ( -
- ))} -
- ); -} - type PlaybackState = 'idle' | 'generating' | 'playing'; export function CapturesTab() { @@ -305,16 +280,6 @@ export function CapturesTab() { session.uploadFile(file, source); }; - const handlePlayOriginal = () => { - if (!selected) return; - setAudioWithAutoPlay( - apiClient.getCaptureAudioUrl(selected.id), - `capture-${selected.id}`, - null, - t('captures.captureCardLabel', { when: formatAbsoluteDate(selected.created_at) }), - ); - }; - const handleCopy = async () => { if (!selected) return; const text = showRefined @@ -550,23 +515,10 @@ export function CapturesTab() { {/* Audio player card */}
-
- - - - {formatDuration(selected.duration_ms)} - -
+
{/* Transcript header */}