chrysopedia/frontend/src/components/TranscriptSidebar.tsx
jlightner 8417f0e9e0 feat: Built WatchPage with video player, synced transcript sidebar, laz…
- "frontend/src/api/videos.ts"
- "frontend/src/components/TranscriptSidebar.tsx"
- "frontend/src/pages/WatchPage.tsx"
- "frontend/src/App.tsx"
- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/App.css"

GSD-Task: S01/T03
2026-04-03 23:50:15 +00:00

124 lines
3.4 KiB
TypeScript

import { useEffect, useRef, useCallback } from "react";
import type { TranscriptSegment } from "../api/videos";
interface TranscriptSidebarProps {
segments: TranscriptSegment[];
currentTime: number;
onSeek: (time: number) => void;
}
/**
* Format seconds as MM:SS.
*/
function formatTimestamp(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
/**
* Binary search to find the active segment index.
* Returns the index of the segment where start_time <= currentTime < end_time,
* or -1 if no segment is active.
* Assumes segments are sorted by start_time.
*/
function findActiveSegment(
segments: TranscriptSegment[],
currentTime: number,
): number {
if (segments.length === 0) return -1;
let lo = 0;
let hi = segments.length - 1;
let result = -1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const seg = segments[mid];
if (seg && seg.start_time <= currentTime) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
// result is the last segment whose start_time <= currentTime
// verify currentTime < end_time for that segment
const active = result >= 0 ? segments[result] : undefined;
if (active && currentTime < active.end_time) {
return result;
}
return -1;
}
/**
* Scrollable transcript sidebar synced to video playback.
* Active segment highlights with cyan border and auto-scrolls into view.
* Clicking a segment seeks the video to that timestamp.
*/
export default function TranscriptSidebar({
segments,
currentTime,
onSeek,
}: TranscriptSidebarProps) {
const containerRef = useRef<HTMLDivElement>(null);
const activeIndexRef = useRef(-1);
const activeIndex = findActiveSegment(segments, currentTime);
// Auto-scroll active segment into view
useEffect(() => {
if (activeIndex < 0 || activeIndex === activeIndexRef.current) return;
activeIndexRef.current = activeIndex;
const container = containerRef.current;
if (!container) return;
const el = container.querySelector<HTMLElement>(
`[data-segment-index="${activeIndex}"]`,
);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [activeIndex]);
const handleClick = useCallback(
(startTime: number) => {
onSeek(startTime);
},
[onSeek],
);
if (segments.length === 0) {
return (
<aside className="transcript-sidebar">
<h3 className="transcript-sidebar__title">Transcript</h3>
<p className="transcript-sidebar__empty">No transcript available</p>
</aside>
);
}
return (
<aside className="transcript-sidebar" ref={containerRef}>
<h3 className="transcript-sidebar__title">Transcript</h3>
<div className="transcript-sidebar__list">
{segments.map((seg, idx) => (
<button
key={seg.id}
type="button"
data-segment-index={idx}
className={`transcript-segment${idx === activeIndex ? " transcript-segment--active" : ""}`}
onClick={() => handleClick(seg.start_time)}
>
<span className="transcript-segment__time">
{formatTimestamp(seg.start_time)}
</span>
<span className="transcript-segment__text">{seg.text}</span>
</button>
))}
</div>
</aside>
);
}