import { useRef, useState, useEffect, useCallback } from "react"; import { Play, Pause, SkipBack, Volume2, VolumeX, Maximize2, Minimize2, VideoIcon, } from "lucide-react"; import { Label } from "@/components/ui/label"; import { AssetPickerSheet } from "../../AssetPickerSheet"; import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc"; import { MediaFallback } from "@/components/generic/MediaFallback"; import { Spinner } from "@/components/ui/spinner"; import { formatPlayerTime as fmtTime } from "@/utils/format.util"; // ─── VideoBlock (Admin) ─────────────────────────────────────────────────────── export function VideoBlock({ content, onUpdate, readOnly = false }) { const [pickerOpen, setPickerOpen] = useState(false); // content.url is redacted (null) server-side for S3 assets — see // redactS3Url() in controllers/admin/assets.controller.js. Re-resolve // through media.util.js instead of trusting the persisted url/thumbnail. const { src, thumbnailUrl, loading } = useAssetPreviewSrc( { asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.thumbnail_url }, { scope: "admin" }, ); const poster = thumbnailUrl ?? content?.thumbnail_url ?? undefined; const vidRef = useRef(null); const [playing, setPlaying] = useState(false); const [progress, setProgress] = useState(0); const [currentTime, setCurrentTime] = useState(0); const [totalDuration, setTotalDuration] = useState(0); const [muted, setMuted] = useState(false); const [volume, setVolume] = useState(1); const [overlayVisible,setOverlayVisible]= useState(true); const [isFullscreen, setIsFullscreen] = useState(false); // True from the moment `src` is set until the browser has actually // buffered enough to render a frame (or stalls mid-playback) — closes the // gap between "token resolved" (the `loading` from useAssetPreviewSrc // above) and "video is actually watchable", which used to render as a // blank black box with no indication anything was happening, especially // on large/slow-loading files. const [mediaLoading, setMediaLoading] = useState(true); const [hoverProgress, setHoverProgress] = useState(null); // { x, time } const previewVidRef = useRef(null); // Reset player when video changes useEffect(() => { setPlaying(false); setProgress(0); setCurrentTime(0); setTotalDuration(0); setOverlayVisible(true); setMediaLoading(true); }, [src]); // ── Video event listeners ───────────────────────────────────────────────── useEffect(() => { const v = vidRef.current; if (!v) return; const onTimeUpdate = () => { setCurrentTime(v.currentTime); if (v.duration) setProgress((v.currentTime / v.duration) * 100); }; const onLoaded = () => setTotalDuration(v.duration); const onEnded = () => { setPlaying(false); setOverlayVisible(true); }; const onLoadedData = () => setMediaLoading(false); const onCanPlay = () => setMediaLoading(false); const onWaiting = () => setMediaLoading(true); const onPlaying = () => setMediaLoading(false); v.addEventListener("timeupdate", onTimeUpdate); v.addEventListener("loadedmetadata", onLoaded); v.addEventListener("ended", onEnded); v.addEventListener("loadeddata", onLoadedData); v.addEventListener("canplay", onCanPlay); v.addEventListener("waiting", onWaiting); v.addEventListener("playing", onPlaying); return () => { v.removeEventListener("timeupdate", onTimeUpdate); v.removeEventListener("loadedmetadata", onLoaded); v.removeEventListener("ended", onEnded); v.removeEventListener("loadeddata", onLoadedData); v.removeEventListener("canplay", onCanPlay); v.removeEventListener("waiting", onWaiting); v.removeEventListener("playing", onPlaying); }; }, [src]); // ── Controls ────────────────────────────────────────────────────────────── const togglePlay = useCallback(() => { const v = vidRef.current; if (!v) return; if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); } else { v.pause(); setPlaying(false); setOverlayVisible(true); } }, []); const restart = () => { const v = vidRef.current; if (!v) return; v.currentTime = 0; v.pause(); setPlaying(false); setOverlayVisible(true); }; const handleSeek = (e) => { const v = vidRef.current; if (!v || !v.duration) return; v.currentTime = (parseFloat(e.target.value) / 100) * v.duration; }; const handleVolumeChange = (e) => { const val = parseFloat(e.target.value); setVolume(val); if (vidRef.current) { vidRef.current.volume = val; vidRef.current.muted = val === 0; } setMuted(val === 0); }; const toggleMute = () => { const v = vidRef.current; if (!v) return; v.muted = !v.muted; setMuted(v.muted); }; const toggleFullscreen = () => { const el = document.getElementById("video-block-wrap"); if (!el) return; if (document.fullscreenElement) document.exitFullscreen(); else el.requestFullscreen?.(); }; // Ignore keystrokes aimed at the seek/volume range inputs so arrow keys // there keep their native behavior instead of double-seeking. const handleKeyDown = useCallback((e) => { if (e.target.tagName === "INPUT") return; const v = vidRef.current; switch (e.key) { case " ": e.preventDefault(); togglePlay(); break; case "ArrowRight": e.preventDefault(); if (v && v.duration) v.currentTime = Math.min(v.currentTime + 5, v.duration); break; case "ArrowLeft": e.preventDefault(); if (v) v.currentTime = Math.max(v.currentTime - 5, 0); break; case "m": e.preventDefault(); toggleMute(); break; case "f": e.preventDefault(); toggleFullscreen(); break; default: break; } }, [togglePlay]); // #video-block-wrap now wraps both the video area and the controls bar // below it (previously just the video), so fullscreen no longer drops the // seek bar / volume / fullscreen button. isFullscreen also relaxes the // 16/9 + max-height constraints so the video fills the screen properly. useEffect(() => { const onChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener("fullscreenchange", onChange); return () => document.removeEventListener("fullscreenchange", onChange); }, []); // ── Asset picker handler ────────────────────────────────────────────────── // // Saves all metadata needed by the client VideoBlock at render time. // Clean object — no ...content spread so stale data never carries over. // // Fields saved: // asset_id — used by client for the secure token flow // url — used by admin player (direct src); ignored by client for S3 // thumbnail_url — poster image for the video player // title — display name (shown in any title-aware blocks) // tag — file extension badge e.g. "MP4" // duration_seconds — probed media length, read by duration.util.js on save // const handleSelect = (asset) => { onUpdate({ asset_id: asset.asset_id, storage_provider: asset.storage_provider ?? null, url: asset.file_url, thumbnail_url: asset.thumbnail_url ?? null, title: asset.display_name, tag: asset.extension?.toUpperCase() ?? "", duration_seconds: Number(asset.duration) || 0, }); setPlaying(false); setProgress(0); setCurrentTime(0); setTotalDuration(0); setOverlayVisible(true); }; // ───────────────────────────────────────────────────────────────────────── return (
{!readOnly && } {loading ? ( ) : src ? (
{/* ── Video area ── */}
{/* ── Player controls ── */}
{/* Progress bar */}
{ const rect = e.currentTarget.getBoundingClientRect(); const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1); const time = pct * (vidRef.current?.duration ?? 0); setHoverProgress({ x: e.clientX - rect.left, time }); if (previewVidRef.current && isFinite(time) && time >= 0) { previewVidRef.current.currentTime = time; } }} onMouseLeave={() => setHoverProgress(null)} >
{/* Scrub preview — hidden video seeked to hover position, no canvas/sprite needed */} {hoverProgress && (
{fmtTime(hoverProgress.time)}
)}
{/* Button row */}
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
{/* ── Change video footer (admin only) ── */} {!readOnly && (
)}
) : ( )} {!readOnly && ( )}
); }