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>
  );
}