v2deepvoice / frontend /src /components /SyncedTranscriptPlayer.tsx
raghu2's picture
feat: integrate 4-stem separation, de-reverb, and ui enhancements
3358253
Raw
History Blame Contribute Delete
6.1 kB
"use client";
import { useEffect, useState, useRef } from 'react';
import { Search, Clock, MessageSquare, VolumeX } from 'lucide-react';
interface WordInfo {
word: string;
start: number;
end: number;
}
interface TimelineMarker {
type: 'speech' | 'silence';
start: number;
end: number;
text?: string;
duration?: number;
}
export default function SyncedTranscriptPlayer({
taskId,
baseUrl,
audioRef
}: {
taskId: string,
baseUrl: string,
audioRef: React.RefObject<HTMLAudioElement | null>
}) {
const [words, setWords] = useState<WordInfo[]>([]);
const [markers, setMarkers] = useState<TimelineMarker[]>([]);
const [currentTime, setCurrentTime] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const activeWordRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
fetch(`${baseUrl}/api/stream/${taskId}/transcript.json`)
.then(res => {
if (!res.ok) throw new Error("Transcript not found");
return res.json();
})
.then(data => {
if (data.words) setWords(data.words);
})
.catch(err => console.log("No transcript found for this task"));
fetch(`${baseUrl}/api/stream/${taskId}/timeline_markers.json`)
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data && data.markers) setMarkers(data.markers);
})
.catch(() => {});
}, [taskId, baseUrl]);
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl) return;
const handleTimeUpdate = () => setCurrentTime(audioEl.currentTime);
audioEl.addEventListener('timeupdate', handleTimeUpdate);
return () => audioEl.removeEventListener('timeupdate', handleTimeUpdate);
}, [audioRef]);
// Auto-scroll to active word if not searching
useEffect(() => {
if (activeWordRef.current && containerRef.current && searchQuery === "") {
activeWordRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}, [currentTime, searchQuery]);
const seekTo = (time: number) => {
if (audioRef.current) {
audioRef.current.currentTime = time;
}
};
if (words.length === 0) return null;
return (
<div className="bg-[#111] border border-[#27272a] rounded-2xl p-6 mb-8 w-full max-w-2xl mx-auto overflow-hidden relative shadow-lg">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 gap-4">
<h4 className="text-lg font-bold text-emerald-400 flex items-center gap-2">
<MessageSquare className="w-5 h-5" /> Audio Intelligence Transcript
</h4>
<div className="relative w-full sm:w-64">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
placeholder="Search transcript..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-[#1a1a1a] border border-[#27272a] rounded-full pl-9 pr-4 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-[#1877F2] transition-colors"
/>
</div>
</div>
{/* Timeline Intelligence Strip */}
{markers.length > 0 && (
<div className="mb-4 bg-[#0a0a0a] rounded-lg p-2 border border-[#27272a]">
<div className="flex items-center gap-2 mb-2 text-xs font-bold text-gray-500 uppercase tracking-widest px-1">
<Clock className="w-3 h-3" /> Timeline Segments
</div>
<div className="flex w-full h-4 rounded overflow-hidden">
{markers.map((m, i) => {
const totalDuration = markers[markers.length-1].end;
const widthPct = ((m.end - m.start) / totalDuration) * 100;
return (
<div
key={i}
title={`${m.type} (${m.start.toFixed(1)}s - ${m.end.toFixed(1)}s)`}
className={`h-full cursor-pointer hover:brightness-125 transition-all ${m.type === 'silence' ? 'bg-[#27272a]' : 'bg-[#1877F2]'}`}
style={{ width: `${widthPct}%` }}
onClick={() => seekTo(m.start)}
/>
)
})}
</div>
<div className="flex justify-between mt-1 px-1 text-[10px] text-gray-600 font-mono">
<span>0:00</span>
<span className="flex items-center gap-1"><VolumeX className="w-3 h-3" /> Gray = Silence</span>
<span>{Math.floor(markers[markers.length-1]?.end || 0)}s</span>
</div>
</div>
)}
<div
ref={containerRef}
className="max-h-64 overflow-y-auto scroll-smooth flex flex-wrap gap-x-2 gap-y-3 p-6 bg-black/80 rounded-xl border border-white/5 relative"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<style dangerouslySetInnerHTML={{__html: `::-webkit-scrollbar { display: none; }`}} />
{words.map((w, i) => {
const isActive = currentTime >= w.start && currentTime <= w.end;
const isPassed = currentTime > w.end;
const matchesSearch = searchQuery && w.word.toLowerCase().includes(searchQuery.toLowerCase());
return (
<span
key={i}
ref={isActive ? activeWordRef : null}
onClick={() => seekTo(w.start)}
className={`text-xl md:text-2xl font-black cursor-pointer transition-all duration-300 hover:text-white ${
matchesSearch ? 'bg-yellow-500/30 text-yellow-300 px-1 rounded' :
isActive ? 'text-[#1877F2] scale-110 drop-shadow-[0_0_8px_rgba(24,119,242,0.8)]' :
isPassed ? 'text-gray-300' : 'text-gray-600'
}`}
style={{
textShadow: isActive ? '0px 0px 10px rgba(24,119,242,0.8)' : 'none'
}}
>
{w.word}
</span>
);
})}
</div>
</div>
);
}