feat: Installed wavesurfer.js, created AudioWaveform component with sha…

- "frontend/src/components/AudioWaveform.tsx"
- "frontend/src/hooks/useMediaSync.ts"
- "frontend/src/pages/WatchPage.tsx"
- "frontend/src/App.css"
- "frontend/package.json"

GSD-Task: S05/T02
This commit is contained in:
jlightner 2026-04-04 05:49:40 +00:00
parent e44ec1d1d5
commit 96608a3d8f
9 changed files with 215 additions and 10 deletions

View file

@ -37,7 +37,7 @@ Also adds `fetchChapters()` to the frontend API client so downstream tasks can c
- Estimate: 45m
- Files: backend/routers/videos.py, backend/schemas.py, frontend/src/api/videos.ts
- Verify: cd /home/aux/projects/content-to-kb-automator/backend && python -c "from routers.videos import router; print('ok')" && grep -q 'fetchChapters' /home/aux/projects/content-to-kb-automator/frontend/src/api/videos.ts
- [ ] **T02: Audio waveform component with wavesurfer.js + WatchPage integration** — Install wavesurfer.js, create the AudioWaveform component, widen useMediaSync to support HTMLMediaElement, and wire the waveform into WatchPage as a replacement for VideoPlayer when no video URL is available.
- [x] **T02: Installed wavesurfer.js, created AudioWaveform component with shared media element, widened useMediaSync to HTMLMediaElement, and wired conditional audio/video rendering in WatchPage** — Install wavesurfer.js, create the AudioWaveform component, widen useMediaSync to support HTMLMediaElement, and wire the waveform into WatchPage as a replacement for VideoPlayer when no video URL is available.
## Steps

View file

@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M021/S05/T01",
"timestamp": 1775281636474,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd /home/aux/projects/content-to-kb-automator/backend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
},
{
"command": "grep -q 'fetchChapters' /home/aux/projects/content-to-kb-automator/frontend/src/api/videos.ts",
"exitCode": 0,
"durationMs": 5,
"verdict": "pass"
}
]
}

View file

@ -0,0 +1,90 @@
---
id: T02
parent: S05
milestone: M021
provides: []
requires: []
affects: []
key_files: ["frontend/src/components/AudioWaveform.tsx", "frontend/src/hooks/useMediaSync.ts", "frontend/src/pages/WatchPage.tsx", "frontend/src/App.css", "frontend/package.json"]
key_decisions: ["wavesurfer.js uses MediaElement backend with shared audio ref so useMediaSync controls playback identically to video mode", "Waveform colors use existing --color-accent (cyan) for theme consistency"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "npx tsc --noEmit exits 0 with no type errors. All grep checks confirm HTMLMediaElement in useMediaSync, wavesurfer in AudioWaveform, AudioWaveform in WatchPage, wavesurfer.js in package.json. All 4 slice-level checks pass (router import, chapters endpoint, stream endpoint, fetchChapters)."
completed_at: 2026-04-04T05:49:32.530Z
blocker_discovered: false
---
# T02: Installed wavesurfer.js, created AudioWaveform component with shared media element, widened useMediaSync to HTMLMediaElement, and wired conditional audio/video rendering in WatchPage
> Installed wavesurfer.js, created AudioWaveform component with shared media element, widened useMediaSync to HTMLMediaElement, and wired conditional audio/video rendering in WatchPage
## What Happened
---
id: T02
parent: S05
milestone: M021
key_files:
- frontend/src/components/AudioWaveform.tsx
- frontend/src/hooks/useMediaSync.ts
- frontend/src/pages/WatchPage.tsx
- frontend/src/App.css
- frontend/package.json
key_decisions:
- wavesurfer.js uses MediaElement backend with shared audio ref so useMediaSync controls playback identically to video mode
- Waveform colors use existing --color-accent (cyan) for theme consistency
duration: ""
verification_result: passed
completed_at: 2026-04-04T05:49:32.531Z
blocker_discovered: false
---
# T02: Installed wavesurfer.js, created AudioWaveform component with shared media element, widened useMediaSync to HTMLMediaElement, and wired conditional audio/video rendering in WatchPage
**Installed wavesurfer.js, created AudioWaveform component with shared media element, widened useMediaSync to HTMLMediaElement, and wired conditional audio/video rendering in WatchPage**
## What Happened
Installed wavesurfer.js as a frontend dependency. Widened useMediaSync ref type from HTMLVideoElement to HTMLMediaElement for audio/video polymorphism. Created AudioWaveform.tsx that renders a hidden audio element shared with useMediaSync and initializes a WaveSurfer instance using the MediaElement backend. Updated WatchPage to conditionally render AudioWaveform when video_url is null, or VideoPlayer when present. Added dark-themed CSS for the waveform container matching the video player area styling.
## Verification
npx tsc --noEmit exits 0 with no type errors. All grep checks confirm HTMLMediaElement in useMediaSync, wavesurfer in AudioWaveform, AudioWaveform in WatchPage, wavesurfer.js in package.json. All 4 slice-level checks pass (router import, chapters endpoint, stream endpoint, fetchChapters).
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 8000ms |
| 2 | `grep -q 'HTMLMediaElement' frontend/src/hooks/useMediaSync.ts` | 0 | ✅ pass | 50ms |
| 3 | `grep -q 'wavesurfer' frontend/src/components/AudioWaveform.tsx` | 0 | ✅ pass | 50ms |
| 4 | `grep -q 'AudioWaveform' frontend/src/pages/WatchPage.tsx` | 0 | ✅ pass | 50ms |
| 5 | `cd backend && python -c "from routers.videos import router; print('ok')"` | 0 | ✅ pass | 1000ms |
| 6 | `grep -q 'def get_video_chapters' backend/routers/videos.py` | 0 | ✅ pass | 50ms |
| 7 | `grep -q 'def stream_video' backend/routers/videos.py` | 0 | ✅ pass | 50ms |
| 8 | `grep -q 'fetchChapters' frontend/src/api/videos.ts` | 0 | ✅ pass | 50ms |
## Deviations
Used project accent color (#22d3ee cyan) instead of plan-suggested #00ffd1 for waveform colors to match existing theme.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/components/AudioWaveform.tsx`
- `frontend/src/hooks/useMediaSync.ts`
- `frontend/src/pages/WatchPage.tsx`
- `frontend/src/App.css`
- `frontend/package.json`
## Deviations
Used project accent color (#22d3ee cyan) instead of plan-suggested #00ffd1 for waveform colors to match existing theme.
## Known Issues
None.

View file

@ -11,7 +11,8 @@
"hls.js": "^1.6.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
"react-router-dom": "^6.28.0",
"wavesurfer.js": "^7.12.5"
},
"devDependencies": {
"@types/react": "^18.3.12",
@ -1884,6 +1885,12 @@
}
}
},
"node_modules/wavesurfer.js": {
"version": "7.12.5",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.5.tgz",
"integrity": "sha512-MSZcA13R9ZlxgYpzfakaSYf8dz5tCdZKYbjtN1qnKbCi+UoyfaTuhvjlXHrITi/fgeO3qWfsH7U3BP1AKnwRNg==",
"license": "BSD-3-Clause"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -12,7 +12,8 @@
"hls.js": "^1.6.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
"react-router-dom": "^6.28.0",
"wavesurfer.js": "^7.12.5"
},
"devDependencies": {
"@types/react": "^18.3.12",

View file

@ -5909,6 +5909,25 @@ a.app-footer__about:hover,
/* ── Player Controls ───────────────────────────────────────────────────────── */
/* ── Audio Waveform (wavesurfer.js) ────────────────────────────────────────── */
.audio-waveform {
background: #000;
border-radius: 8px;
padding: 1rem;
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: center;
border: 1px solid var(--color-border);
}
.audio-waveform__canvas {
width: 100%;
}
/* ── Player Controls (continued) ───────────────────────────────────────────── */
.player-controls {
display: flex;
align-items: center;

View file

@ -0,0 +1,58 @@
import { useEffect, useRef } from "react";
import WaveSurfer from "wavesurfer.js";
import type { MediaSyncState } from "../hooks/useMediaSync";
interface AudioWaveformProps {
mediaSync: MediaSyncState;
src: string;
}
/**
* 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.
*/
export default function AudioWaveform({ mediaSync, src }: AudioWaveformProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WaveSurfer | null>(null);
useEffect(() => {
const container = containerRef.current;
const audio = mediaSync.videoRef.current as HTMLAudioElement | null;
if (!container || !audio) return;
const ws = WaveSurfer.create({
container,
media: audio,
height: 128,
waveColor: "rgba(34, 211, 238, 0.4)",
progressColor: "rgba(34, 211, 238, 0.8)",
cursorColor: "#22d3ee",
barWidth: 2,
barGap: 1,
barRadius: 2,
backend: "MediaElement",
});
wsRef.current = ws;
return () => {
ws.destroy();
wsRef.current = null;
};
// Re-create when src changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src]);
return (
<div className="audio-waveform">
<audio
ref={mediaSync.videoRef as React.RefObject<HTMLAudioElement>}
src={src}
preload="metadata"
style={{ display: "none" }}
/>
<div ref={containerRef} className="audio-waveform__canvas" />
</div>
);
}

View file

@ -7,7 +7,7 @@ export interface MediaSyncState {
playbackRate: number;
volume: number;
isMuted: boolean;
videoRef: React.RefObject<HTMLVideoElement | null>;
videoRef: React.RefObject<HTMLMediaElement | null>;
seekTo: (time: number) => void;
setPlaybackRate: (rate: number) => void;
togglePlay: () => void;
@ -22,7 +22,7 @@ export interface MediaSyncState {
* timeupdate, play, pause, ratechange, volumechange, and loadedmetadata.
*/
export function useMediaSync(): MediaSyncState {
const videoRef = useRef<HTMLVideoElement | null>(null);
const videoRef = useRef<HTMLMediaElement | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);

View file

@ -3,9 +3,11 @@ 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 { BASE } from "../api/client";
import type { VideoDetail, TranscriptSegment } from "../api/videos";
import { ApiError } from "../api/client";
import VideoPlayer from "../components/VideoPlayer";
import AudioWaveform from "../components/AudioWaveform";
import PlayerControls from "../components/PlayerControls";
import TranscriptSidebar from "../components/TranscriptSidebar";
@ -90,6 +92,8 @@ export default function WatchPage() {
if (!video) return null;
const streamUrl = `${BASE}/videos/${videoId}/stream`;
return (
<div className="watch-page">
<header className="watch-page__header">
@ -106,11 +110,15 @@ export default function WatchPage() {
<div className="watch-page__content">
<div className="watch-page__player-area">
<VideoPlayer
src={video.video_url ?? null}
startTime={startTime}
mediaSync={mediaSync}
/>
{video.video_url ? (
<VideoPlayer
src={video.video_url}
startTime={startTime}
mediaSync={mediaSync}
/>
) : (
<AudioWaveform src={streamUrl} mediaSync={mediaSync} />
)}
<PlayerControls mediaSync={mediaSync} />
</div>