- "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
124 lines
3.4 KiB
TypeScript
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>
|
|
);
|
|
}
|