chrysopedia/frontend/src/components/VideoPlayer.tsx
jlightner 8069e9e2a3 perf: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and na…
- "frontend/src/hooks/useMediaSync.ts"
- "frontend/src/components/VideoPlayer.tsx"
- "frontend/src/components/PlayerControls.tsx"
- "frontend/src/App.css"
- "frontend/package.json"

GSD-Task: S01/T02
2026-04-03 23:46:03 +00:00

138 lines
4 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import type { MediaSyncState } from "../hooks/useMediaSync";
interface VideoPlayerProps {
src: string | null;
startTime?: number;
mediaSync: MediaSyncState;
}
/**
* Video player with HLS support (lazy-loaded via dynamic import),
* Safari native HLS fallback, and direct .mp4 playback.
* Shows a placeholder when src is null.
*/
export default function VideoPlayer({ src, startTime, mediaSync }: VideoPlayerProps) {
const { videoRef } = mediaSync;
const hlsRef = useRef<import("hls.js").default | null>(null);
const [error, setError] = useState<string | null>(null);
const startTimeSeeked = useRef(false);
// Clamp startTime: NaN / negative → 0
const safeStart =
startTime != null && Number.isFinite(startTime) && startTime > 0
? startTime
: 0;
// Seek to startTime once after metadata loads
useEffect(() => {
const el = videoRef.current;
if (!el || safeStart === 0) return;
startTimeSeeked.current = false;
const onLoaded = () => {
if (!startTimeSeeked.current) {
el.currentTime = safeStart;
startTimeSeeked.current = true;
}
};
if (el.readyState >= 1) {
onLoaded();
} else {
el.addEventListener("loadedmetadata", onLoaded, { once: true });
return () => el.removeEventListener("loadedmetadata", onLoaded);
}
}, [videoRef, safeStart]);
// HLS / native source attachment
useEffect(() => {
const el = videoRef.current;
if (!el || !src) return;
setError(null);
let destroyed = false;
const isHlsUrl = src.endsWith(".m3u8");
// Check native HLS support (Safari)
const canPlayHlsNatively = el.canPlayType("application/vnd.apple.mpegurl") !== "";
if (isHlsUrl && !canPlayHlsNatively) {
// Lazy-load hls.js
void import("hls.js").then(({ default: Hls }) => {
if (destroyed) return;
if (!Hls.isSupported()) {
setError("HLS playback is not supported in this browser.");
return;
}
const hls = new Hls({
enableWorker: true,
startPosition: safeStart > 0 ? safeStart : -1,
});
hlsRef.current = hls;
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
console.error("[VideoPlayer] Fatal HLS error:", data.type, data.details);
setError(`Playback error: ${data.details}`);
hls.destroy();
hlsRef.current = null;
}
});
hls.loadSource(src);
hls.attachMedia(el);
});
} else {
// Native HLS (Safari) or plain .mp4
el.src = src;
}
return () => {
destroyed = true;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
}, [src, videoRef, safeStart]);
if (!src) {
return (
<div className="video-player video-player__unavailable">
<div className="video-player__unavailable-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z" />
<path d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12z" />
<line x1="4" y1="4" x2="20" y2="20" strokeLinecap="round" />
</svg>
<p>Video not available</p>
<span>The source video for this content has not been linked yet.</span>
</div>
</div>
);
}
if (error) {
return (
<div className="video-player video-player__unavailable">
<div className="video-player__unavailable-content">
<p>Playback Error</p>
<span>{error}</span>
</div>
</div>
);
}
return (
<div className="video-player">
<video
ref={videoRef as React.RefObject<HTMLVideoElement>}
className="video-player__video"
playsInline
preload="metadata"
/>
</div>
);
}