Spaces:
Sleeping
Sleeping
File size: 6,095 Bytes
1b42461 1451bab 1b42461 1451bab 1b42461 1451bab 1b42461 1451bab 1b42461 1451bab 1b42461 3358253 1b42461 1451bab 1b42461 1451bab 1b42461 1451bab 1b42461 1451bab 1b42461 1451bab 1b42461 1451bab 1b42461 1451bab 1b42461 1451bab 1b42461 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | "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>
);
}
|