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:
parent
e44ec1d1d5
commit
96608a3d8f
9 changed files with 215 additions and 10 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
22
.gsd/milestones/M021/slices/S05/tasks/T01-VERIFY.json
Normal file
22
.gsd/milestones/M021/slices/S05/tasks/T01-VERIFY.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
90
.gsd/milestones/M021/slices/S05/tasks/T02-SUMMARY.md
Normal file
90
.gsd/milestones/M021/slices/S05/tasks/T02-SUMMARY.md
Normal 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.
|
||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
58
frontend/src/components/AudioWaveform.tsx
Normal file
58
frontend/src/components/AudioWaveform.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue