feat: Created ChapterMarkers overlay component, added RegionsPlugin cha…

- "frontend/src/components/ChapterMarkers.tsx"
- "frontend/src/components/PlayerControls.tsx"
- "frontend/src/components/AudioWaveform.tsx"
- "frontend/src/pages/WatchPage.tsx"
- "frontend/src/App.css"

GSD-Task: S05/T03
This commit is contained in:
jlightner 2026-04-04 05:53:19 +00:00
parent 2949c93c86
commit ee00f288d9
5 changed files with 162 additions and 21 deletions

View file

@ -6050,6 +6050,67 @@ a.app-footer__about:hover,
font-weight: 600;
}
/* ── Seek container + Chapter markers ──────────────────────────────────────── */
.player-controls__seek-container {
position: relative;
flex: 1;
display: flex;
align-items: center;
}
.player-controls__seek-container .player-controls__seek {
width: 100%;
}
.chapter-markers {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.chapter-marker__tick {
position: absolute;
width: 3px;
height: 100%;
background: var(--color-accent, #22d3ee);
opacity: 0.6;
pointer-events: all;
cursor: pointer;
transform: translateX(-50%);
border: none;
padding: 0;
font: inherit;
}
.chapter-marker__tick:hover {
opacity: 1;
}
.chapter-marker__tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--color-bg-surface, #1e293b);
color: var(--text-primary, #e2e8f0);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 150ms;
margin-bottom: 4px;
}
.chapter-marker__tick:hover .chapter-marker__tooltip {
opacity: 1;
}
/* ── Responsive: player controls ───────────────────────────────────────────── */
@media (max-width: 640px) {

View file

@ -1,18 +1,22 @@
import { useEffect, useRef } from "react";
import WaveSurfer from "wavesurfer.js";
import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js";
import type { MediaSyncState } from "../hooks/useMediaSync";
import type { Chapter } from "../api/videos";
interface AudioWaveformProps {
mediaSync: MediaSyncState;
src: string;
chapters?: Chapter[];
}
/**
* Audio-only waveform visualiser powered by wavesurfer.js.
* Renders a hidden <audio> element owned by useMediaSync and an
* interactive waveform. Used when no video URL is available.
* Optionally displays chapter regions via the RegionsPlugin.
*/
export default function AudioWaveform({ mediaSync, src }: AudioWaveformProps) {
export default function AudioWaveform({ mediaSync, src, chapters }: AudioWaveformProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WaveSurfer | null>(null);
@ -21,6 +25,8 @@ export default function AudioWaveform({ mediaSync, src }: AudioWaveformProps) {
const audio = mediaSync.videoRef.current as HTMLAudioElement | null;
if (!container || !audio) return;
const regions = RegionsPlugin.create();
const ws = WaveSurfer.create({
container,
media: audio,
@ -32,17 +38,34 @@ export default function AudioWaveform({ mediaSync, src }: AudioWaveformProps) {
barGap: 1,
barRadius: 2,
backend: "MediaElement",
plugins: [regions],
});
wsRef.current = ws;
// Add chapter regions once waveform is ready
if (chapters && chapters.length > 0) {
ws.on("ready", () => {
for (const ch of chapters) {
regions.addRegion({
start: ch.start_time,
end: ch.end_time,
content: ch.title,
color: "rgba(0, 255, 209, 0.1)",
drag: false,
resize: false,
});
}
});
}
return () => {
ws.destroy();
wsRef.current = null;
};
// Re-create when src changes
// Re-create when src or chapters change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src]);
}, [src, chapters]);
return (
<div className="audio-waveform">

View file

@ -0,0 +1,39 @@
import type { Chapter } from "../api/videos";
interface ChapterMarkersProps {
chapters: Chapter[];
duration: number;
onSeek: (time: number) => void;
}
/**
* Absolutely-positioned overlay that renders tick marks on the seek bar
* for each chapter. Ticks are clickable and show a tooltip on hover.
*/
export default function ChapterMarkers({ chapters, duration, onSeek }: ChapterMarkersProps) {
if (!duration || chapters.length === 0) return null;
return (
<div className="chapter-markers">
{chapters.map((ch) => {
const leftPct = (ch.start_time / duration) * 100;
return (
<button
key={ch.id}
className="chapter-marker__tick"
style={{ left: `${leftPct}%` }}
onClick={(e) => {
e.stopPropagation();
onSeek(ch.start_time);
}}
aria-label={`Jump to chapter: ${ch.title}`}
title={ch.title}
type="button"
>
<span className="chapter-marker__tooltip">{ch.title}</span>
</button>
);
})}
</div>
);
}

View file

@ -1,10 +1,14 @@
import { useCallback, useEffect } from "react";
import type { MediaSyncState } from "../hooks/useMediaSync";
import type { Chapter } from "../api/videos";
import ChapterMarkers from "./ChapterMarkers";
interface PlayerControlsProps {
mediaSync: MediaSyncState;
/** Ref to the container element for fullscreen requests */
containerRef?: React.RefObject<HTMLElement | null>;
/** Optional chapter markers to overlay on the seek bar */
chapters?: Chapter[];
}
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
@ -16,7 +20,7 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function PlayerControls({ mediaSync, containerRef }: PlayerControlsProps) {
export default function PlayerControls({ mediaSync, containerRef, chapters }: PlayerControlsProps) {
const {
currentTime,
duration,
@ -121,19 +125,24 @@ export default function PlayerControls({ mediaSync, containerRef }: PlayerContro
{formatTime(currentTime)} / {formatTime(duration)}
</span>
{/* Seek bar */}
<input
type="range"
className="player-controls__seek"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onChange={handleSeek}
disabled={seekDisabled}
aria-label="Seek"
style={{ "--progress": `${seekProgress}%` } as React.CSSProperties}
/>
{/* Seek bar with chapter markers */}
<div className="player-controls__seek-container">
<input
type="range"
className="player-controls__seek"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onChange={handleSeek}
disabled={seekDisabled}
aria-label="Seek"
style={{ "--progress": `${seekProgress}%` } as React.CSSProperties}
/>
{chapters && chapters.length > 0 && (
<ChapterMarkers chapters={chapters} duration={duration} onSeek={seekTo} />
)}
</div>
{/* Speed selector */}
<div className="player-controls__speed" role="group" aria-label="Playback speed">

View file

@ -2,9 +2,9 @@ import { useEffect, useState } from "react";
import { useParams, useSearchParams, Link } from "react-router-dom";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import { useMediaSync } from "../hooks/useMediaSync";
import { fetchVideo, fetchTranscript } from "../api/videos";
import { fetchVideo, fetchTranscript, fetchChapters } from "../api/videos";
import { BASE } from "../api/client";
import type { VideoDetail, TranscriptSegment } from "../api/videos";
import type { VideoDetail, TranscriptSegment, Chapter } from "../api/videos";
import { ApiError } from "../api/client";
import VideoPlayer from "../components/VideoPlayer";
import AudioWaveform from "../components/AudioWaveform";
@ -24,6 +24,7 @@ export default function WatchPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [transcriptError, setTranscriptError] = useState(false);
const [chapters, setChapters] = useState<Chapter[]>([]);
const mediaSync = useMediaSync();
@ -61,6 +62,14 @@ export default function WatchPage() {
if (!cancelled) setTranscriptError(true);
}
// Fetch chapters — non-critical, fail silently
try {
const c = await fetchChapters(videoId);
if (!cancelled) setChapters(c.chapters);
} catch {
// chapters are optional — ignore errors
}
if (!cancelled) setLoading(false);
})();
@ -117,9 +126,9 @@ export default function WatchPage() {
mediaSync={mediaSync}
/>
) : (
<AudioWaveform src={streamUrl} mediaSync={mediaSync} />
<AudioWaveform src={streamUrl} mediaSync={mediaSync} chapters={chapters} />
)}
<PlayerControls mediaSync={mediaSync} />
<PlayerControls mediaSync={mediaSync} chapters={chapters} />
</div>
<TranscriptSidebar