- "frontend/src/App.tsx" - "frontend/src/pages/WatchPage.tsx" - "frontend/src/App.css" GSD-Task: S03/T02
168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
import { useEffect, useState, useCallback } from "react";
|
|
import { useParams, useSearchParams, Link } from "react-router-dom";
|
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
|
import { useMediaSync } from "../hooks/useMediaSync";
|
|
import { fetchVideo, fetchTranscript, fetchChapters } from "../api/videos";
|
|
import { BASE } from "../api/client";
|
|
import type { VideoDetail, TranscriptSegment, Chapter } 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";
|
|
import { copyToClipboard } from "../utils/clipboard";
|
|
|
|
export default function WatchPage() {
|
|
const { videoId } = useParams<{ videoId: string }>();
|
|
const [searchParams] = useSearchParams();
|
|
|
|
// Parse ?t= param — clamp to 0 for NaN/negative
|
|
const rawT = parseFloat(searchParams.get("t") ?? "");
|
|
const startTime = Number.isFinite(rawT) && rawT > 0 ? rawT : 0;
|
|
|
|
const [video, setVideo] = useState<VideoDetail | null>(null);
|
|
const [segments, setSegments] = useState<TranscriptSegment[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [transcriptError, setTranscriptError] = useState(false);
|
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
|
const [embedCopied, setEmbedCopied] = useState(false);
|
|
|
|
const mediaSync = useMediaSync();
|
|
|
|
const handleCopyEmbed = useCallback(async () => {
|
|
const height = video?.video_url ? 405 : 120;
|
|
const snippet = `<iframe src="${window.location.origin}/embed/${videoId}" width="720" height="${height}" frameborder="0" allowfullscreen></iframe>`;
|
|
const ok = await copyToClipboard(snippet);
|
|
if (ok) {
|
|
setEmbedCopied(true);
|
|
setTimeout(() => setEmbedCopied(false), 2000);
|
|
}
|
|
}, [videoId, video?.video_url]);
|
|
|
|
useDocumentTitle(video ? `${video.filename} — Chrysopedia` : "Loading…");
|
|
|
|
// Fetch video detail
|
|
useEffect(() => {
|
|
if (!videoId) return;
|
|
let cancelled = false;
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
setTranscriptError(false);
|
|
|
|
(async () => {
|
|
try {
|
|
const v = await fetchVideo(videoId);
|
|
if (!cancelled) setVideo(v);
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
setError("Video not found");
|
|
} else {
|
|
setError("Failed to load video");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fetch transcript separately — player works without it
|
|
try {
|
|
const t = await fetchTranscript(videoId);
|
|
if (!cancelled) setSegments(t.segments);
|
|
} catch {
|
|
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);
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [videoId]);
|
|
|
|
if (loading && !video) {
|
|
return (
|
|
<div className="watch-page watch-page--loading">
|
|
<p style={{ color: "var(--text-secondary)", textAlign: "center", padding: "4rem 0" }}>
|
|
Loading video…
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="watch-page watch-page--error">
|
|
<div style={{ textAlign: "center", padding: "4rem 0" }}>
|
|
<h2 style={{ color: "var(--text-primary)", marginBottom: "0.5rem" }}>{error}</h2>
|
|
<Link to="/" style={{ color: "var(--accent)" }}>← Back to home</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!video) return null;
|
|
|
|
const streamUrl = `${BASE}/videos/${videoId}/stream`;
|
|
|
|
return (
|
|
<div className="watch-page">
|
|
<header className="watch-page__header">
|
|
<div className="watch-page__header-top">
|
|
<h1 className="watch-page__title">{video.filename}</h1>
|
|
<button
|
|
className={`watch-page__embed-btn${embedCopied ? " watch-page__embed-btn--copied" : ""}`}
|
|
onClick={handleCopyEmbed}
|
|
>
|
|
{embedCopied ? "Copied!" : "Copy Embed Code"}
|
|
</button>
|
|
</div>
|
|
{video.creator_name && video.creator_slug && (
|
|
<Link
|
|
to={`/creators/${video.creator_slug}`}
|
|
className="watch-page__creator"
|
|
>
|
|
{video.creator_name}
|
|
</Link>
|
|
)}
|
|
</header>
|
|
|
|
<div className="watch-page__content">
|
|
<div className="watch-page__player-area">
|
|
{video.video_url ? (
|
|
<VideoPlayer
|
|
src={video.video_url}
|
|
startTime={startTime}
|
|
mediaSync={mediaSync}
|
|
/>
|
|
) : (
|
|
<AudioWaveform src={streamUrl} mediaSync={mediaSync} chapters={chapters} />
|
|
)}
|
|
<PlayerControls mediaSync={mediaSync} chapters={chapters} />
|
|
</div>
|
|
|
|
<TranscriptSidebar
|
|
segments={segments}
|
|
currentTime={mediaSync.currentTime}
|
|
onSeek={mediaSync.seekTo}
|
|
/>
|
|
</div>
|
|
|
|
{transcriptError && (
|
|
<p className="watch-page__transcript-error">
|
|
Transcript unavailable — playback continues without it.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|