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
|
- Estimate: 45m
|
||||||
- Files: backend/routers/videos.py, backend/schemas.py, frontend/src/api/videos.ts
|
- 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
|
- 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
|
## 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",
|
"hls.js": "^1.6.15",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
|
|
|
||||||
|
|
@ -5909,6 +5909,25 @@ a.app-footer__about:hover,
|
||||||
|
|
||||||
/* ── Player Controls ───────────────────────────────────────────────────────── */
|
/* ── 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 {
|
.player-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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;
|
playbackRate: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
videoRef: React.RefObject<HTMLMediaElement | null>;
|
||||||
seekTo: (time: number) => void;
|
seekTo: (time: number) => void;
|
||||||
setPlaybackRate: (rate: number) => void;
|
setPlaybackRate: (rate: number) => void;
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
|
|
@ -22,7 +22,7 @@ export interface MediaSyncState {
|
||||||
* timeupdate, play, pause, ratechange, volumechange, and loadedmetadata.
|
* timeupdate, play, pause, ratechange, volumechange, and loadedmetadata.
|
||||||
*/
|
*/
|
||||||
export function useMediaSync(): MediaSyncState {
|
export function useMediaSync(): MediaSyncState {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLMediaElement | null>(null);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { useParams, useSearchParams, Link } from "react-router-dom";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
import { useMediaSync } from "../hooks/useMediaSync";
|
import { useMediaSync } from "../hooks/useMediaSync";
|
||||||
import { fetchVideo, fetchTranscript } from "../api/videos";
|
import { fetchVideo, fetchTranscript } from "../api/videos";
|
||||||
|
import { BASE } from "../api/client";
|
||||||
import type { VideoDetail, TranscriptSegment } from "../api/videos";
|
import type { VideoDetail, TranscriptSegment } from "../api/videos";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import VideoPlayer from "../components/VideoPlayer";
|
import VideoPlayer from "../components/VideoPlayer";
|
||||||
|
import AudioWaveform from "../components/AudioWaveform";
|
||||||
import PlayerControls from "../components/PlayerControls";
|
import PlayerControls from "../components/PlayerControls";
|
||||||
import TranscriptSidebar from "../components/TranscriptSidebar";
|
import TranscriptSidebar from "../components/TranscriptSidebar";
|
||||||
|
|
||||||
|
|
@ -90,6 +92,8 @@ export default function WatchPage() {
|
||||||
|
|
||||||
if (!video) return null;
|
if (!video) return null;
|
||||||
|
|
||||||
|
const streamUrl = `${BASE}/videos/${videoId}/stream`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="watch-page">
|
<div className="watch-page">
|
||||||
<header className="watch-page__header">
|
<header className="watch-page__header">
|
||||||
|
|
@ -106,11 +110,15 @@ export default function WatchPage() {
|
||||||
|
|
||||||
<div className="watch-page__content">
|
<div className="watch-page__content">
|
||||||
<div className="watch-page__player-area">
|
<div className="watch-page__player-area">
|
||||||
|
{video.video_url ? (
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
src={video.video_url ?? null}
|
src={video.video_url}
|
||||||
startTime={startTime}
|
startTime={startTime}
|
||||||
mediaSync={mediaSync}
|
mediaSync={mediaSync}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<AudioWaveform src={streamUrl} mediaSync={mediaSync} />
|
||||||
|
)}
|
||||||
<PlayerControls mediaSync={mediaSync} />
|
<PlayerControls mediaSync={mediaSync} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue