Spaces:
Sleeping
Sleeping
feat: bidirectional timeline ↔ transcript sync with scroll highlight
Browse files- 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
|
| 94 |
-
active ? "bg-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
)
|