mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
404 lines
16 KiB
React
404 lines
16 KiB
React
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 (
|
|
<div className="space-y-3">
|
|
{!readOnly && <Label>Video</Label>}
|
|
|
|
{loading ? (
|
|
<MediaFallback className="w-full aspect-video rounded-lg" />
|
|
) : src ? (
|
|
<div
|
|
id="video-block-wrap"
|
|
tabIndex={0}
|
|
onKeyDown={handleKeyDown}
|
|
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 flex flex-col" : "rounded-lg border"}`}
|
|
>
|
|
|
|
{/* ── Video area ── */}
|
|
<div
|
|
className={`relative w-full bg-black cursor-pointer group ${isFullscreen ? "flex-1 min-h-0" : ""}`}
|
|
style={isFullscreen ? undefined : { aspectRatio: "16/9", maxHeight: "calc(100vh - 260px)" }}
|
|
onClick={togglePlay}
|
|
>
|
|
<video
|
|
ref={vidRef}
|
|
src={src}
|
|
poster={poster}
|
|
preload="metadata"
|
|
playsInline
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
|
|
{/* Loading spinner — covers the gap between src resolving and the
|
|
browser actually having a frame to show */}
|
|
{mediaLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 pointer-events-none">
|
|
<Spinner className="size-8 text-white" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Play/pause overlay */}
|
|
{!mediaLoading && (
|
|
<div
|
|
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${overlayVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
|
style={{ background: "rgba(0,0,0,0.3)" }}
|
|
>
|
|
<button
|
|
aria-label={playing ? "Pause" : "Play"}
|
|
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
|
className="w-12 h-12 rounded-full bg-white/90 hover:bg-white flex items-center justify-center transition-transform hover:scale-105"
|
|
>
|
|
{playing
|
|
? <Pause className="size-4 text-black" />
|
|
: <Play className="size-4 text-black ml-0.5" />
|
|
}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Change video hover hint */}
|
|
{!readOnly && (
|
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
className="text-xs bg-black/60 hover:bg-black/80 text-white px-2.5 py-1 rounded-md transition-colors"
|
|
onClick={(e) => { e.stopPropagation(); setPickerOpen(true); }}
|
|
>
|
|
Change
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Player controls ── */}
|
|
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-2">
|
|
|
|
{/* Progress bar */}
|
|
<div
|
|
className="relative h-1 bg-border rounded-full cursor-pointer"
|
|
onMouseMove={(e) => {
|
|
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)}
|
|
>
|
|
<div
|
|
className="h-full bg-foreground rounded-full transition-[width] duration-100"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
<input
|
|
type="range" min="0" max="100" step="0.1"
|
|
value={progress}
|
|
onChange={handleSeek}
|
|
aria-label="Seek"
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
/>
|
|
|
|
{/* Scrub preview — hidden video seeked to hover position, no canvas/sprite needed */}
|
|
{hoverProgress && (
|
|
<div
|
|
className="absolute bottom-3 flex flex-col items-center pointer-events-none z-30"
|
|
style={{ left: `${hoverProgress.x}px`, transform: "translateX(-50%)" }}
|
|
>
|
|
<div className="rounded-md overflow-hidden border border-white/20 shadow-xl bg-black" style={{ width: 160, height: 90 }}>
|
|
<video
|
|
ref={previewVidRef}
|
|
src={src}
|
|
preload="auto"
|
|
muted
|
|
playsInline
|
|
disablePictureInPicture
|
|
disableRemotePlayback
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
<span className="text-white text-xs mt-1 font-medium tabular-nums drop-shadow bg-black/70 px-1.5 py-0.5 rounded">
|
|
{fmtTime(hoverProgress.time)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Button row */}
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className="text-muted-foreground hover:text-foreground transition-colors">
|
|
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
|
</button>
|
|
<button onClick={restart} aria-label="Restart" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
<SkipBack className="size-4" />
|
|
</button>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
|
|
</span>
|
|
|
|
<div className="flex items-center gap-1.5 ml-auto">
|
|
<button onClick={toggleMute} aria-label="Toggle mute" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
|
</button>
|
|
<input
|
|
type="range" min="0" max="1" step="0.05"
|
|
value={muted ? 0 : volume}
|
|
onChange={handleVolumeChange}
|
|
aria-label="Volume"
|
|
className="w-16 accent-foreground"
|
|
/>
|
|
</div>
|
|
|
|
<button onClick={toggleFullscreen} aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} className="text-muted-foreground hover:text-foreground transition-colors ml-1">
|
|
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Change video footer (admin only) ── */}
|
|
{!readOnly && (
|
|
<div className="px-3 pb-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPickerOpen(true)}
|
|
className="w-full text-sm text-muted-foreground border rounded-md py-1.5 hover:bg-muted transition-colors"
|
|
>
|
|
Change video
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPickerOpen(true)}
|
|
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
|
>
|
|
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
|
|
<p className="text-sm text-muted-foreground">Click to select a video</p>
|
|
</button>
|
|
)}
|
|
|
|
{!readOnly && (
|
|
<AssetPickerSheet
|
|
open={pickerOpen}
|
|
onOpenChange={setPickerOpen}
|
|
fileType="video"
|
|
onSelect={handleSelect}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
} |