mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 13:20:39 -07:00
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:
co-authored by
Claude Opus 4.7
parent
c9103a24da
commit
67a9e308a9
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user