mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
465 lines
19 KiB
React
465 lines
19 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 wrapRef = 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);
|
|
// Native Fullscreen API works on desktop (all browsers) and Android Chrome.
|
|
// iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
|
|
// pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
|
|
// custom controls instead of falling back to native <video> fullscreen.
|
|
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
|
const [pseudoFullscreen, setPseudoFullscreen] = useState(false);
|
|
const isFullscreen = nativeFullscreen || pseudoFullscreen;
|
|
// 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); }
|
|
}, []);
|
|
|
|
// Safari doesn't focus a <div tabIndex> on click, which silently kills
|
|
// keyboard shortcuts afterwards — force it explicitly.
|
|
const handleAreaClick = useCallback(() => {
|
|
wrapRef.current?.focus({ preventScroll: true });
|
|
togglePlay();
|
|
}, [togglePlay]);
|
|
|
|
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 = () => {
|
|
// Uses wrapRef instead of getElementById so this works correctly even if
|
|
// an admin page ever renders more than one VideoBlock at once (an id
|
|
// lookup would always grab the first match, toggling the wrong player).
|
|
const el = wrapRef.current;
|
|
if (!el) return;
|
|
if (isFullscreen) {
|
|
if (document.fullscreenElement) document.exitFullscreen();
|
|
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
|
|
else setPseudoFullscreen(false);
|
|
} else if (el.requestFullscreen) {
|
|
el.requestFullscreen();
|
|
} else if (el.webkitRequestFullscreen) {
|
|
// Desktop Safari — supports element fullscreen via the prefixed API.
|
|
el.webkitRequestFullscreen();
|
|
} else {
|
|
// iOS Safari has no element-level Fullscreen API at all.
|
|
setPseudoFullscreen(true);
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
|
|
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
|
|
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
|
switch (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;
|
|
case "Escape":
|
|
if (isFullscreen) {
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
}
|
|
break;
|
|
default: break;
|
|
}
|
|
}, [togglePlay, toggleMute, toggleFullscreen, isFullscreen]);
|
|
|
|
// #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(() => {
|
|
// Safari (incl. desktop) still fires the prefixed webkitfullscreenchange
|
|
// event for element-level fullscreen, so both are listened for.
|
|
const onChange = () => {
|
|
const active = !!(document.fullscreenElement || document.webkitFullscreenElement);
|
|
setNativeFullscreen(active);
|
|
// Safari doesn't focus a <div tabIndex> on click, so keyboard
|
|
// shortcuts silently stop working after entering fullscreen unless we
|
|
// explicitly refocus the wrapper here.
|
|
if (active) wrapRef.current?.focus({ preventScroll: true });
|
|
};
|
|
document.addEventListener("fullscreenchange", onChange);
|
|
document.addEventListener("webkitfullscreenchange", onChange);
|
|
return () => {
|
|
document.removeEventListener("fullscreenchange", onChange);
|
|
document.removeEventListener("webkitfullscreenchange", onChange);
|
|
};
|
|
}, []);
|
|
|
|
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
|
|
// reclaim keyboard focus while it's active, restore on exit/unmount.
|
|
useEffect(() => {
|
|
if (!pseudoFullscreen) return;
|
|
const prevOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
wrapRef.current?.focus({ preventScroll: true });
|
|
return () => { document.body.style.overflow = prevOverflow; };
|
|
}, [pseudoFullscreen]);
|
|
|
|
// ── 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"
|
|
ref={wrapRef}
|
|
tabIndex={0}
|
|
onKeyDown={handleKeyDown}
|
|
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 z-[100] 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={handleAreaClick}
|
|
>
|
|
<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>
|
|
);
|
|
} |