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
4edb96df2b
commit
2949c93c86
6 changed files with 102 additions and 9 deletions
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">
|
||||||
<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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue