Lior-0618 commited on
Commit
8d76fde
·
1 Parent(s): f900ec0

feat: bidirectional timeline ↔ transcript sync with scroll highlight

Browse files
Files changed (1) hide show
  1. web/src/app/studio/page.tsx +65 -8
web/src/app/studio/page.tsx CHANGED
@@ -80,20 +80,27 @@ function SegmentRow({
80
  active,
81
  speaker,
82
  onClick,
 
83
  }: {
84
  seg: Segment
85
  active: boolean
86
  speaker: SpeakerInfo
87
  onClick: () => void
 
88
  }) {
89
  return (
90
  <div
 
91
  onClick={onClick}
92
  className={cn(
93
- "flex items-start gap-12 group transition-all duration-300 cursor-pointer py-4 rounded-lg px-4 border border-transparent",
94
- active ? "bg-accent/[0.04] border-accent/10" : "hover:bg-muted/30"
95
  )}
96
  >
 
 
 
 
97
  {/* Speaker */}
98
  <div className="w-32 flex items-center gap-3 shrink-0 pt-1">
99
  <Avatar className="size-6 ring-2 ring-background border border-border/20">
@@ -329,6 +336,8 @@ function TimelineBar({
329
  duration,
330
  currentTime,
331
  speakerMap,
 
 
332
  }: {
333
  isPlaying: boolean
334
  onToggle: () => void
@@ -336,9 +345,18 @@ function TimelineBar({
336
  duration: number
337
  currentTime: number
338
  speakerMap: Record<string, SpeakerInfo>
 
 
339
  }) {
340
  const speakers = Object.entries(speakerMap)
341
 
 
 
 
 
 
 
 
342
  return (
343
  <div className="border-t border-border bg-background flex-shrink-0">
344
  <div className="flex overflow-hidden">
@@ -368,8 +386,8 @@ function TimelineBar({
368
  </div>
369
  </div>
370
 
371
- {/* Tracks Area */}
372
- <div className="flex-1 relative overflow-hidden">
373
  {/* Time Marks */}
374
  <div className="h-12 border-b border-border/40 flex items-center relative px-4">
375
  <Badge variant="secondary" className="bg-black text-white text-[10px] h-5 px-1.5 rounded-[4px] absolute left-4 z-10">0.00</Badge>
@@ -387,7 +405,13 @@ function TimelineBar({
387
  {duration > 0 && segments.filter(s => s.speaker === id).map(seg => (
388
  <div
389
  key={seg.id}
390
- className={cn("absolute top-2 bottom-2 rounded-[6px] transition-opacity hover:opacity-80 cursor-alias", info.trackColor)}
 
 
 
 
 
 
391
  style={{
392
  left: `${(seg.start / duration) * 100}%`,
393
  width: `${Math.max(((seg.end - seg.start) / duration) * 100, 0.5)}%`,
@@ -401,9 +425,7 @@ function TimelineBar({
401
  {duration > 0 && (
402
  <div
403
  className="absolute top-0 bottom-0 w-px bg-black z-20 pointer-events-none transition-all duration-75"
404
- style={{
405
- left: (currentTime / duration) * 100 + "%"
406
- }}
407
  />
408
  )}
409
  </div>
@@ -448,6 +470,8 @@ function StudioContent() {
448
  // Ref-based guard: set synchronously before any await to prevent double-fetch
449
  // even when React re-runs the effect before setIsProcessing(true) is committed.
450
  const processingRef = useRef(false)
 
 
451
 
452
  const [session, setSession] = useState(() => sessionId ? getSession(sessionId) : null)
453
  const [activeId, setActiveId] = useState<number>(1)
@@ -538,6 +562,33 @@ function StudioContent() {
538
 
539
  const isVideo = isVideoFile(filename)
540
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  const handleSegmentClick = (seg: Segment) => {
542
  setActiveId(seg.id)
543
  if (mediaRef.current && audioUrl) {
@@ -644,6 +695,10 @@ function StudioContent() {
644
  active={seg.id === activeId}
645
  speaker={speakerMap[seg.speaker]}
646
  onClick={() => handleSegmentClick(seg)}
 
 
 
 
647
  />
648
  </React.Fragment>
649
  ))}
@@ -677,6 +732,8 @@ function StudioContent() {
677
  duration={duration}
678
  currentTime={currentTime}
679
  speakerMap={speakerMap}
 
 
680
  />
681
  </div>
682
  )
 
80
  active,
81
  speaker,
82
  onClick,
83
+ containerRef,
84
  }: {
85
  seg: Segment
86
  active: boolean
87
  speaker: SpeakerInfo
88
  onClick: () => void
89
+ containerRef?: (el: HTMLDivElement | null) => void
90
  }) {
91
  return (
92
  <div
93
+ ref={containerRef}
94
  onClick={onClick}
95
  className={cn(
96
+ "relative flex items-start gap-12 group transition-all duration-300 cursor-pointer py-4 rounded-lg px-4 border",
97
+ active ? "bg-primary/[0.03] border-primary/15" : "border-transparent hover:bg-muted/30"
98
  )}
99
  >
100
+ {/* Active left accent bar */}
101
+ {active && (
102
+ <div className="absolute left-0 top-3 bottom-3 w-[3px] bg-primary/50 rounded-full transition-all duration-300" />
103
+ )}
104
  {/* Speaker */}
105
  <div className="w-32 flex items-center gap-3 shrink-0 pt-1">
106
  <Avatar className="size-6 ring-2 ring-background border border-border/20">
 
336
  duration,
337
  currentTime,
338
  speakerMap,
339
+ activeSegId,
340
+ onSeek,
341
  }: {
342
  isPlaying: boolean
343
  onToggle: () => void
 
345
  duration: number
346
  currentTime: number
347
  speakerMap: Record<string, SpeakerInfo>
348
+ activeSegId: number
349
+ onSeek: (time: number) => void
350
  }) {
351
  const speakers = Object.entries(speakerMap)
352
 
353
+ function handleTracksClick(e: React.MouseEvent<HTMLDivElement>) {
354
+ if (duration <= 0) return
355
+ const rect = e.currentTarget.getBoundingClientRect()
356
+ const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
357
+ onSeek(ratio * duration)
358
+ }
359
+
360
  return (
361
  <div className="border-t border-border bg-background flex-shrink-0">
362
  <div className="flex overflow-hidden">
 
386
  </div>
387
  </div>
388
 
389
+ {/* Tracks Area — click anywhere to seek */}
390
+ <div className="flex-1 relative overflow-hidden cursor-pointer" onClick={handleTracksClick}>
391
  {/* Time Marks */}
392
  <div className="h-12 border-b border-border/40 flex items-center relative px-4">
393
  <Badge variant="secondary" className="bg-black text-white text-[10px] h-5 px-1.5 rounded-[4px] absolute left-4 z-10">0.00</Badge>
 
405
  {duration > 0 && segments.filter(s => s.speaker === id).map(seg => (
406
  <div
407
  key={seg.id}
408
+ className={cn(
409
+ "absolute top-2 bottom-2 rounded-[6px] transition-all duration-150",
410
+ info.trackColor,
411
+ seg.id === activeSegId
412
+ ? "opacity-100 ring-1 ring-foreground/30 ring-inset"
413
+ : "opacity-70 hover:opacity-90"
414
+ )}
415
  style={{
416
  left: `${(seg.start / duration) * 100}%`,
417
  width: `${Math.max(((seg.end - seg.start) / duration) * 100, 0.5)}%`,
 
425
  {duration > 0 && (
426
  <div
427
  className="absolute top-0 bottom-0 w-px bg-black z-20 pointer-events-none transition-all duration-75"
428
+ style={{ left: (currentTime / duration) * 100 + "%" }}
 
 
429
  />
430
  )}
431
  </div>
 
470
  // Ref-based guard: set synchronously before any await to prevent double-fetch
471
  // even when React re-runs the effect before setIsProcessing(true) is committed.
472
  const processingRef = useRef(false)
473
+ // Per-segment DOM element refs for auto-scroll
474
+ const segmentRefs = useRef<Map<number, HTMLDivElement>>(new Map())
475
 
476
  const [session, setSession] = useState(() => sessionId ? getSession(sessionId) : null)
477
  const [activeId, setActiveId] = useState<number>(1)
 
562
 
563
  const isVideo = isVideoFile(filename)
564
 
565
+ // Update activeId based on current playback time
566
+ useEffect(() => {
567
+ if (!isPlaying) return
568
+ const seg = segments.find(s => currentTime >= s.start && currentTime < s.end)
569
+ if (seg && seg.id !== activeId) {
570
+ setActiveId(seg.id)
571
+ }
572
+ }, [currentTime, segments, isPlaying, activeId])
573
+
574
+ // Auto-scroll transcript to active segment whenever activeId changes
575
+ useEffect(() => {
576
+ const el = segmentRefs.current.get(activeId)
577
+ if (el) {
578
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" })
579
+ }
580
+ }, [activeId])
581
+
582
+ const handleSeek = (time: number) => {
583
+ if (mediaRef.current) {
584
+ mediaRef.current.currentTime = time
585
+ setCurrentTime(time)
586
+ }
587
+ // Immediately highlight the segment at the seeked time
588
+ const seg = segments.find(s => time >= s.start && time < s.end)
589
+ if (seg) setActiveId(seg.id)
590
+ }
591
+
592
  const handleSegmentClick = (seg: Segment) => {
593
  setActiveId(seg.id)
594
  if (mediaRef.current && audioUrl) {
 
695
  active={seg.id === activeId}
696
  speaker={speakerMap[seg.speaker]}
697
  onClick={() => handleSegmentClick(seg)}
698
+ containerRef={el => {
699
+ if (el) segmentRefs.current.set(seg.id, el)
700
+ else segmentRefs.current.delete(seg.id)
701
+ }}
702
  />
703
  </React.Fragment>
704
  ))}
 
732
  duration={duration}
733
  currentTime={currentTime}
734
  speakerMap={speakerMap}
735
+ activeSegId={activeId}
736
+ onSeek={handleSeek}
737
  />
738
  </div>
739
  )