feat: Created ChapterMarkers overlay component, added RegionsPlugin cha…
- "frontend/src/components/ChapterMarkers.tsx" - "frontend/src/components/PlayerControls.tsx" - "frontend/src/components/AudioWaveform.tsx" - "frontend/src/pages/WatchPage.tsx" - "frontend/src/App.css" GSD-Task: S05/T03
This commit is contained in:
parent
2949c93c86
commit
ee00f288d9
5 changed files with 162 additions and 21 deletions
|
|
@ -6050,6 +6050,67 @@ a.app-footer__about:hover,
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Seek container + Chapter markers ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.player-controls__seek-container {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls__seek-container .player-controls__seek {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-markers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-marker__tick {
|
||||||
|
position: absolute;
|
||||||
|
width: 3px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-accent, #22d3ee);
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: all;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-marker__tick:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-marker__tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--color-bg-surface, #1e293b);
|
||||||
|
color: var(--text-primary, #e2e8f0);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 150ms;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-marker__tick:hover .chapter-marker__tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive: player controls ───────────────────────────────────────────── */
|
/* ── Responsive: player controls ───────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import WaveSurfer from "wavesurfer.js";
|
import WaveSurfer from "wavesurfer.js";
|
||||||
|
import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js";
|
||||||
import type { MediaSyncState } from "../hooks/useMediaSync";
|
import type { MediaSyncState } from "../hooks/useMediaSync";
|
||||||
|
import type { Chapter } from "../api/videos";
|
||||||
|
|
||||||
interface AudioWaveformProps {
|
interface AudioWaveformProps {
|
||||||
mediaSync: MediaSyncState;
|
mediaSync: MediaSyncState;
|
||||||
src: string;
|
src: string;
|
||||||
|
chapters?: Chapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio-only waveform visualiser powered by wavesurfer.js.
|
* Audio-only waveform visualiser powered by wavesurfer.js.
|
||||||
* Renders a hidden <audio> element owned by useMediaSync and an
|
* Renders a hidden <audio> element owned by useMediaSync and an
|
||||||
* interactive waveform. Used when no video URL is available.
|
* interactive waveform. Used when no video URL is available.
|
||||||
|
* Optionally displays chapter regions via the RegionsPlugin.
|
||||||
*/
|
*/
|
||||||
export default function AudioWaveform({ mediaSync, src }: AudioWaveformProps) {
|
export default function AudioWaveform({ mediaSync, src, chapters }: AudioWaveformProps) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const wsRef = useRef<WaveSurfer | null>(null);
|
const wsRef = useRef<WaveSurfer | null>(null);
|
||||||
|
|
||||||
|
|
@ -21,6 +25,8 @@ export default function AudioWaveform({ mediaSync, src }: AudioWaveformProps) {
|
||||||
const audio = mediaSync.videoRef.current as HTMLAudioElement | null;
|
const audio = mediaSync.videoRef.current as HTMLAudioElement | null;
|
||||||
if (!container || !audio) return;
|
if (!container || !audio) return;
|
||||||
|
|
||||||
|
const regions = RegionsPlugin.create();
|
||||||
|
|
||||||
const ws = WaveSurfer.create({
|
const ws = WaveSurfer.create({
|
||||||
container,
|
container,
|
||||||
media: audio,
|
media: audio,
|
||||||
|
|
@ -32,17 +38,34 @@ export default function AudioWaveform({ mediaSync, src }: AudioWaveformProps) {
|
||||||
barGap: 1,
|
barGap: 1,
|
||||||
barRadius: 2,
|
barRadius: 2,
|
||||||
backend: "MediaElement",
|
backend: "MediaElement",
|
||||||
|
plugins: [regions],
|
||||||
});
|
});
|
||||||
|
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
// Add chapter regions once waveform is ready
|
||||||
|
if (chapters && chapters.length > 0) {
|
||||||
|
ws.on("ready", () => {
|
||||||
|
for (const ch of chapters) {
|
||||||
|
regions.addRegion({
|
||||||
|
start: ch.start_time,
|
||||||
|
end: ch.end_time,
|
||||||
|
content: ch.title,
|
||||||
|
color: "rgba(0, 255, 209, 0.1)",
|
||||||
|
drag: false,
|
||||||
|
resize: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ws.destroy();
|
ws.destroy();
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
};
|
};
|
||||||
// Re-create when src changes
|
// Re-create when src or chapters change
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [src]);
|
}, [src, chapters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="audio-waveform">
|
<div className="audio-waveform">
|
||||||
|
|
|
||||||
39
frontend/src/components/ChapterMarkers.tsx
Normal file
39
frontend/src/components/ChapterMarkers.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { Chapter } from "../api/videos";
|
||||||
|
|
||||||
|
interface ChapterMarkersProps {
|
||||||
|
chapters: Chapter[];
|
||||||
|
duration: number;
|
||||||
|
onSeek: (time: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolutely-positioned overlay that renders tick marks on the seek bar
|
||||||
|
* for each chapter. Ticks are clickable and show a tooltip on hover.
|
||||||
|
*/
|
||||||
|
export default function ChapterMarkers({ chapters, duration, onSeek }: ChapterMarkersProps) {
|
||||||
|
if (!duration || chapters.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chapter-markers">
|
||||||
|
{chapters.map((ch) => {
|
||||||
|
const leftPct = (ch.start_time / duration) * 100;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ch.id}
|
||||||
|
className="chapter-marker__tick"
|
||||||
|
style={{ left: `${leftPct}%` }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSeek(ch.start_time);
|
||||||
|
}}
|
||||||
|
aria-label={`Jump to chapter: ${ch.title}`}
|
||||||
|
title={ch.title}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="chapter-marker__tooltip">{ch.title}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import type { MediaSyncState } from "../hooks/useMediaSync";
|
import type { MediaSyncState } from "../hooks/useMediaSync";
|
||||||
|
import type { Chapter } from "../api/videos";
|
||||||
|
import ChapterMarkers from "./ChapterMarkers";
|
||||||
|
|
||||||
interface PlayerControlsProps {
|
interface PlayerControlsProps {
|
||||||
mediaSync: MediaSyncState;
|
mediaSync: MediaSyncState;
|
||||||
/** Ref to the container element for fullscreen requests */
|
/** Ref to the container element for fullscreen requests */
|
||||||
containerRef?: React.RefObject<HTMLElement | null>;
|
containerRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
/** Optional chapter markers to overlay on the seek bar */
|
||||||
|
chapters?: Chapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
||||||
|
|
@ -16,7 +20,7 @@ function formatTime(seconds: number): string {
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlayerControls({ mediaSync, containerRef }: PlayerControlsProps) {
|
export default function PlayerControls({ mediaSync, containerRef, chapters }: PlayerControlsProps) {
|
||||||
const {
|
const {
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
|
|
@ -121,7 +125,8 @@ export default function PlayerControls({ mediaSync, containerRef }: PlayerContro
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Seek bar */}
|
{/* Seek bar with chapter markers */}
|
||||||
|
<div className="player-controls__seek-container">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="player-controls__seek"
|
className="player-controls__seek"
|
||||||
|
|
@ -134,6 +139,10 @@ export default function PlayerControls({ mediaSync, containerRef }: PlayerContro
|
||||||
aria-label="Seek"
|
aria-label="Seek"
|
||||||
style={{ "--progress": `${seekProgress}%` } as React.CSSProperties}
|
style={{ "--progress": `${seekProgress}%` } as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
|
{chapters && chapters.length > 0 && (
|
||||||
|
<ChapterMarkers chapters={chapters} duration={duration} onSeek={seekTo} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Speed selector */}
|
{/* Speed selector */}
|
||||||
<div className="player-controls__speed" role="group" aria-label="Playback speed">
|
<div className="player-controls__speed" role="group" aria-label="Playback speed">
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ import { useEffect, useState } from "react";
|
||||||
import { useParams, useSearchParams, Link } from "react-router-dom";
|
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, fetchChapters } from "../api/videos";
|
||||||
import { BASE } from "../api/client";
|
import { BASE } from "../api/client";
|
||||||
import type { VideoDetail, TranscriptSegment } from "../api/videos";
|
import type { VideoDetail, TranscriptSegment, Chapter } 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 AudioWaveform from "../components/AudioWaveform";
|
||||||
|
|
@ -24,6 +24,7 @@ export default function WatchPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [transcriptError, setTranscriptError] = useState(false);
|
const [transcriptError, setTranscriptError] = useState(false);
|
||||||
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
|
|
||||||
const mediaSync = useMediaSync();
|
const mediaSync = useMediaSync();
|
||||||
|
|
||||||
|
|
@ -61,6 +62,14 @@ export default function WatchPage() {
|
||||||
if (!cancelled) setTranscriptError(true);
|
if (!cancelled) setTranscriptError(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch chapters — non-critical, fail silently
|
||||||
|
try {
|
||||||
|
const c = await fetchChapters(videoId);
|
||||||
|
if (!cancelled) setChapters(c.chapters);
|
||||||
|
} catch {
|
||||||
|
// chapters are optional — ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -117,9 +126,9 @@ export default function WatchPage() {
|
||||||
mediaSync={mediaSync}
|
mediaSync={mediaSync}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AudioWaveform src={streamUrl} mediaSync={mediaSync} />
|
<AudioWaveform src={streamUrl} mediaSync={mediaSync} chapters={chapters} />
|
||||||
)}
|
)}
|
||||||
<PlayerControls mediaSync={mediaSync} />
|
<PlayerControls mediaSync={mediaSync} chapters={chapters} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TranscriptSidebar
|
<TranscriptSidebar
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue