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) <[email protected]>
This commit is contained in:
James Pine
2026-04-23 19:20:42 -07:00
co-authored by Claude Opus 4.7
parent c9103a24da
commit 67a9e308a9
2 changed files with 158 additions and 53 deletions
@@ -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<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(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<string | null>(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 (
<div className={cn('flex items-center gap-4', className)}>
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full shrink-0"
onClick={handlePlayPause}
disabled={isLoading || !!error}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isPlaying ? (
<Pause className="h-4 w-4 fill-current" />
) : (
<Play className="h-4 w-4 ml-0.5 fill-current" />
)}
</Button>
<div ref={waveformRef} className="flex-1 min-w-0 h-10 select-none" />
<span className="text-xs tabular-nums text-muted-foreground font-medium shrink-0">
{error ? '—' : formatDuration(displayMs)}
</span>
</div>
);
}
+5 -53
View File
@@ -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 (
<div className={cn('flex items-center gap-[2px] h-10', className)}>
{bars.map((h, i) => (
<div
key={i}
className="w-[3px] rounded-full bg-foreground/25"
style={{ height: `${h}%` }}
/>
))}
</div>
);
}
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 */}
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-6">
<div className="flex items-center gap-4">
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full shrink-0"
onClick={handlePlayOriginal}
>
<Play className="h-4 w-4 ml-0.5" />
</Button>
<FakeWaveform
seed={selected.id.charCodeAt(0)}
className="flex-1"
/>
<span className="text-xs tabular-nums text-muted-foreground font-medium">
{formatDuration(selected.duration_ms)}
</span>
</div>
<CaptureInlinePlayer
audioUrl={apiClient.getCaptureAudioUrl(selected.id)}
fallbackDurationMs={selected.duration_ms}
/>
</div>
{/* Transcript header */}