mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize2,
|
||||
VideoIcon,
|
||||
} from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
|
||||
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
|
||||
|
||||
export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
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);
|
||||
|
||||
// Reset player when video changes
|
||||
useEffect(() => {
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
setTotalDuration(0);
|
||||
setOverlayVisible(true);
|
||||
}, [content.url]);
|
||||
|
||||
// ── 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); };
|
||||
|
||||
v.addEventListener("timeupdate", onTimeUpdate);
|
||||
v.addEventListener("loadedmetadata", onLoaded);
|
||||
v.addEventListener("ended", onEnded);
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||
v.removeEventListener("loadedmetadata", onLoaded);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
};
|
||||
}, [content.url]);
|
||||
|
||||
// ── 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?.();
|
||||
};
|
||||
|
||||
// ── 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"
|
||||
//
|
||||
const handleSelect = (asset) => {
|
||||
onUpdate({
|
||||
asset_id: asset.asset_id,
|
||||
url: asset.file_url,
|
||||
thumbnail_url: asset.thumbnail_url ?? null,
|
||||
title: asset.display_name,
|
||||
tag: asset.extension?.toUpperCase() ?? "",
|
||||
});
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
setTotalDuration(0);
|
||||
setOverlayVisible(true);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{!readOnly && <Label>Video</Label>}
|
||||
|
||||
{content.url ? (
|
||||
<div className="rounded-lg overflow-hidden bg-card">
|
||||
|
||||
{/* ── Video area ── */}
|
||||
<div
|
||||
id="video-block-wrap"
|
||||
className="relative w-full bg-black cursor-pointer group"
|
||||
style={{ aspectRatio: "16/9" }}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
<video
|
||||
ref={vidRef}
|
||||
src={content.url}
|
||||
poster={content.thumbnail_url ?? undefined}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Play/pause overlay */}
|
||||
<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">
|
||||
<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"
|
||||
/>
|
||||
</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="Fullscreen" className="text-muted-foreground hover:text-foreground transition-colors ml-1">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user