- "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
138 lines
4 KiB
TypeScript
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>
|
|
);
|
|
}
|