mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-28 22:55:15 -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,
|
FileAudio,
|
||||||
Loader2,
|
Loader2,
|
||||||
Mic,
|
Mic,
|
||||||
Play,
|
|
||||||
Send,
|
Send,
|
||||||
Settings2,
|
Settings2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CapturePill } from '@/components/CapturePill/CapturePill';
|
import { CapturePill } from '@/components/CapturePill/CapturePill';
|
||||||
|
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
|
||||||
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
|
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
type PlaybackState = 'idle' | 'generating' | 'playing';
|
||||||
|
|
||||||
export function CapturesTab() {
|
export function CapturesTab() {
|
||||||
@@ -305,16 +280,6 @@ export function CapturesTab() {
|
|||||||
session.uploadFile(file, source);
|
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 () => {
|
const handleCopy = async () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const text = showRefined
|
const text = showRefined
|
||||||
@@ -550,23 +515,10 @@ export function CapturesTab() {
|
|||||||
|
|
||||||
{/* Audio player card */}
|
{/* Audio player card */}
|
||||||
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-6">
|
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<CaptureInlinePlayer
|
||||||
<Button
|
audioUrl={apiClient.getCaptureAudioUrl(selected.id)}
|
||||||
size="icon"
|
fallbackDurationMs={selected.duration_ms}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transcript header */}
|
{/* Transcript header */}
|
||||||
|
|||||||
Reference in New Issue
Block a user