chrysopedia/frontend/src/pages/WatchPage.tsx
jlightner 18e9a4dce1 feat: Wired /embed/:videoId route outside AppShell for chrome-free rend…
- "frontend/src/App.tsx"
- "frontend/src/pages/WatchPage.tsx"
- "frontend/src/App.css"

GSD-Task: S03/T02
2026-04-04 10:59:14 +00:00

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