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:
parent
96608a3d8f
commit
76880d0477
8 changed files with 288 additions and 22 deletions
|
|
@ -73,7 +73,7 @@ Also adds `fetchChapters()` to the frontend API client so downstream tasks can c
|
|||
- Estimate: 1h30m
|
||||
- Files: frontend/src/components/AudioWaveform.tsx, frontend/src/hooks/useMediaSync.ts, frontend/src/pages/WatchPage.tsx, frontend/src/components/VideoPlayer.tsx, frontend/src/App.css, frontend/package.json
|
||||
- Verify: cd /home/aux/projects/content-to-kb-automator/frontend && npx tsc --noEmit && grep -q 'HTMLMediaElement' src/hooks/useMediaSync.ts && grep -q 'AudioWaveform' src/pages/WatchPage.tsx
|
||||
- [ ] **T03: Chapter markers on seek bar + waveform regions + integration CSS** — Create a ChapterMarkers overlay component for the seek bar, add chapter region display in the waveform, load chapter data in WatchPage, and polish the integration CSS.
|
||||
- [x] **T03: Created ChapterMarkers overlay component, added RegionsPlugin chapter regions to AudioWaveform, wired chapter fetching in WatchPage, and added chapter marker CSS** — Create a ChapterMarkers overlay component for the seek bar, add chapter region display in the waveform, load chapter data in WatchPage, and polish the integration CSS.
|
||||
|
||||
## Steps
|
||||
|
||||
|
|
|
|||
36
.gsd/milestones/M021/slices/S05/tasks/T02-VERIFY.json
Normal file
36
.gsd/milestones/M021/slices/S05/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T02",
|
||||
"unitId": "M021/S05/T02",
|
||||
"timestamp": 1775281780853,
|
||||
"passed": false,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd /home/aux/projects/content-to-kb-automator/frontend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 7,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "npx tsc --noEmit",
|
||||
"exitCode": 1,
|
||||
"durationMs": 765,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "grep -q 'HTMLMediaElement' src/hooks/useMediaSync.ts",
|
||||
"exitCode": 2,
|
||||
"durationMs": 9,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "grep -q 'AudioWaveform' src/pages/WatchPage.tsx",
|
||||
"exitCode": 2,
|
||||
"durationMs": 8,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
89
.gsd/milestones/M021/slices/S05/tasks/T03-SUMMARY.md
Normal file
89
.gsd/milestones/M021/slices/S05/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
id: T03
|
||||
parent: S05
|
||||
milestone: M021
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["frontend/src/components/ChapterMarkers.tsx", "frontend/src/components/PlayerControls.tsx", "frontend/src/components/AudioWaveform.tsx", "frontend/src/pages/WatchPage.tsx", "frontend/src/App.css"]
|
||||
key_decisions: ["Chapter ticks use button elements for keyboard accessibility", "RegionsPlugin registered at WaveSurfer creation, regions added on ready event"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "All verification checks pass: tsc --noEmit exits 0, ChapterMarkers found in PlayerControls, fetchChapters found in WatchPage, chapter-marker found in App.css, plus all slice-level checks (backend import, endpoints, HTMLMediaElement, AudioWaveform)."
|
||||
completed_at: 2026-04-04T05:53:15.920Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Created ChapterMarkers overlay component, added RegionsPlugin chapter regions to AudioWaveform, wired chapter fetching in WatchPage, and added chapter marker CSS
|
||||
|
||||
> Created ChapterMarkers overlay component, added RegionsPlugin chapter regions to AudioWaveform, wired chapter fetching in WatchPage, and added chapter marker CSS
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T03
|
||||
parent: S05
|
||||
milestone: M021
|
||||
key_files:
|
||||
- frontend/src/components/ChapterMarkers.tsx
|
||||
- frontend/src/components/PlayerControls.tsx
|
||||
- frontend/src/components/AudioWaveform.tsx
|
||||
- frontend/src/pages/WatchPage.tsx
|
||||
- frontend/src/App.css
|
||||
key_decisions:
|
||||
- Chapter ticks use button elements for keyboard accessibility
|
||||
- RegionsPlugin registered at WaveSurfer creation, regions added on ready event
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-04T05:53:15.920Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Created ChapterMarkers overlay component, added RegionsPlugin chapter regions to AudioWaveform, wired chapter fetching in WatchPage, and added chapter marker CSS
|
||||
|
||||
**Created ChapterMarkers overlay component, added RegionsPlugin chapter regions to AudioWaveform, wired chapter fetching in WatchPage, and added chapter marker CSS**
|
||||
|
||||
## What Happened
|
||||
|
||||
Created ChapterMarkers.tsx as an overlay component rendering positioned tick marks on the seek bar. Updated PlayerControls to accept chapters prop and wrap seek input in a container div. Added RegionsPlugin to AudioWaveform for labeled chapter regions. Updated WatchPage to fetch chapters and pass them to both components. Added all chapter marker CSS styles to App.css.
|
||||
|
||||
## Verification
|
||||
|
||||
All verification checks pass: tsc --noEmit exits 0, ChapterMarkers found in PlayerControls, fetchChapters found in WatchPage, chapter-marker found in App.css, plus all slice-level checks (backend import, endpoints, HTMLMediaElement, AudioWaveform).
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 3000ms |
|
||||
| 2 | `grep -q 'ChapterMarkers' frontend/src/components/PlayerControls.tsx` | 0 | ✅ pass | 50ms |
|
||||
| 3 | `grep -q 'fetchChapters' frontend/src/pages/WatchPage.tsx` | 0 | ✅ pass | 50ms |
|
||||
| 4 | `grep -q 'chapter-marker' frontend/src/App.css` | 0 | ✅ pass | 50ms |
|
||||
| 5 | `cd backend && python -c "from routers.videos import router; print('ok')"` | 0 | ✅ pass | 500ms |
|
||||
| 6 | `grep -q 'HTMLMediaElement' frontend/src/hooks/useMediaSync.ts` | 0 | ✅ pass | 50ms |
|
||||
| 7 | `grep -q 'AudioWaveform' frontend/src/pages/WatchPage.tsx` | 0 | ✅ pass | 50ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Used button elements instead of divs for chapter ticks to improve accessibility. Added margin-bottom to tooltip for spacing.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `frontend/src/components/ChapterMarkers.tsx`
|
||||
- `frontend/src/components/PlayerControls.tsx`
|
||||
- `frontend/src/components/AudioWaveform.tsx`
|
||||
- `frontend/src/pages/WatchPage.tsx`
|
||||
- `frontend/src/App.css`
|
||||
|
||||
|
||||
## Deviations
|
||||
Used button elements instead of divs for chapter ticks to improve accessibility. Added margin-bottom to tooltip for spacing.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
39
frontend/src/components/ChapterMarkers.tsx
Normal file
39
frontend/src/components/ChapterMarkers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue