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 4edb96df2b
commit 2949c93c86
6 changed files with 102 additions and 9 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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;

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; 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);

View file

@ -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">
<VideoPlayer {video.video_url ? (
src={video.video_url ?? null} <VideoPlayer
startTime={startTime} src={video.video_url}
mediaSync={mediaSync} startTime={startTime}
/> mediaSync={mediaSync}
/>
) : (
<AudioWaveform src={streamUrl} mediaSync={mediaSync} />
)}
<PlayerControls mediaSync={mediaSync} /> <PlayerControls mediaSync={mediaSync} />
</div> </div>