mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
730 lines
37 KiB
React
730 lines
37 KiB
React
import { useRef, useState, useEffect, useCallback } from "react";
|
|
import {
|
|
Play, Pause, SkipBack, Volume2, VolumeX, Maximize2, Minimize2, Settings, VideoIcon,
|
|
Volume1, SkipForward, Gauge, X, RotateCcw,
|
|
} from "lucide-react";
|
|
import {
|
|
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
import api from "@/utils/api.util";
|
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
|
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
|
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
|
|
|
|
const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
|
|
|
|
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
|
|
|
// ─── Tooltip control button ───────────────────────────────────────────────────
|
|
|
|
function CtrlBtn({ label, onClick, children, className = "" }) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={label}
|
|
onClick={onClick}
|
|
className={`text-white/80 hover:text-white transition-colors flex items-center justify-center ${className}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="text-sm">{label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// ─── Settings panel ───────────────────────────────────────────────────────────
|
|
|
|
function SettingsPanel({ speed, onSpeed, onClose }) {
|
|
const [tab, setTab] = useState(null);
|
|
|
|
const Row = ({ icon: Icon, label, value, onClick }) => (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-white/10 transition-colors text-sm"
|
|
>
|
|
<span className="flex items-center gap-2.5 text-white/90">
|
|
<Icon className="size-4 text-white/50" />
|
|
{label}
|
|
</span>
|
|
<div className="text-white/50 flex items-center gap-2">
|
|
<p>{value}</p><ChevronRight className="size-4" />
|
|
</div>
|
|
</button>
|
|
);
|
|
|
|
const OptionList = ({ options, current, onSelect }) => (
|
|
<div className="py-1">
|
|
{options.map((opt) => (
|
|
<button
|
|
key={opt} type="button"
|
|
onClick={() => { onSelect(opt); setTab(null); }}
|
|
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/10 flex items-center justify-between ${current === opt ? "text-white font-medium" : "text-white/60"}`}
|
|
>
|
|
{opt}
|
|
{current === opt && <span className="text-white text-xs">✓</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="hidden lg:block absolute bottom-12 right-2 z-20 w-70 rounded-xl overflow-hidden shadow-2xl border border-white/10"
|
|
style={{ background: "rgba(18,18,18,0.96)", backdropFilter: "blur(16px)" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{tab === null && (
|
|
<>
|
|
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/30 px-4 pt-3 pb-1">Settings</p>
|
|
<Row icon={Gauge} label="Playback speed" value={speed} onClick={() => setTab("speed")} />
|
|
<div className="h-2" />
|
|
</>
|
|
)}
|
|
{tab !== null && (
|
|
<>
|
|
<button type="button" onClick={() => setTab(null)} className="flex items-center gap-1.5 px-4 pt-3 pb-1 text-sm text-white/40 hover:text-white/70 transition-colors w-full">
|
|
<ChevronLeft className="size-4" /> Playback speed
|
|
</button>
|
|
<OptionList options={PLAYBACK_SPEEDS} current={speed} onSelect={onSpeed} />
|
|
<div className="h-1" />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className="lg:hidden absolute bottom-0 left-0 right-0 z-20 rounded-t-2xl border-t border-white/10 overflow-hidden"
|
|
style={{ background: "rgba(18,18,18,0.98)", backdropFilter: "blur(20px)" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between px-4 pt-2.5 pb-1">
|
|
<div className="w-8 h-1 rounded-full bg-white/20 mx-auto" />
|
|
<button type="button" onClick={(e) => { e.stopPropagation(); onClose(); }} className="absolute right-3 top-3 text-white/40 hover:text-white transition-colors">
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
{tab === null && (
|
|
<>
|
|
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/30 px-4 pt-2 pb-1">Settings</p>
|
|
<Row icon={Gauge} label="Playback speed" value={speed} onClick={() => setTab("speed")} />
|
|
<div className="h-safe pb-4" />
|
|
</>
|
|
)}
|
|
{tab !== null && (
|
|
<>
|
|
<button type="button" onClick={() => setTab(null)} className="flex items-center gap-1.5 px-4 pt-3 pb-1 text-sm text-white/40 hover:text-white/70 transition-colors w-full">
|
|
<ChevronLeft className="size-4" /> Playback speed
|
|
</button>
|
|
<div className="flex flex-wrap gap-2 px-4 py-3">
|
|
{PLAYBACK_SPEEDS.map((opt) => (
|
|
<button
|
|
key={opt} type="button"
|
|
onClick={() => { onSpeed(opt); setTab(null); }}
|
|
className={`px-4 py-1.5 rounded-full text-sm border transition-colors ${speed === opt ? "bg-white text-black border-white font-medium" : "bg-white/10 text-white/70 border-white/10 hover:bg-white/20"}`}
|
|
>
|
|
{opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="pb-4" />
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ─── VideoBlock (Client — secure) ────────────────────────────────────────────
|
|
//
|
|
// S3/Garage:
|
|
// 1. POST /client/media/token → get JWT token
|
|
// 2. fetch(streamUrl, { credentials: "include" }) → get raw bytes
|
|
// 3. URL.createObjectURL(blob) → blob:http://... URL
|
|
// 4. <video src="blob:..."> → real URL never visible in DOM
|
|
//
|
|
// Chibisafe:
|
|
// → content.url used directly (Chibisafe CDN, no proxy needed)
|
|
//
|
|
// content shape: { asset_id, url, storage_provider, thumbnail_url? }
|
|
|
|
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
|
|
// immediately at 100% on end. Backing the watch_percent completion requirement type;
|
|
// harmless/unused when the lesson isn't configured for it (caller just won't pass it).
|
|
export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
|
|
const wrapRef = useRef(null);
|
|
const vidRef = useRef(null);
|
|
const guard = useMediaWatchGuard({ onWatchProgress, antiSkipEnabled });
|
|
const resumedRef = useRef(false);
|
|
|
|
// ── Stream state ──────────────────────────────────────────────────────────
|
|
const [blobUrl, setBlobUrl] = useState(null);
|
|
const [freshThumb, setFreshThumb] = useState(null);
|
|
const [fetchLoading, setFetchLoading] = useState(false);
|
|
const [fetchError, setFetchError] = useState(false);
|
|
|
|
// ── Player state ──────────────────────────────────────────────────────────
|
|
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 [ended, setEnded] = useState(false);
|
|
const [overlayVisible, setOverlayVisible] = useState(true);
|
|
const [controlsVisible, setControlsVisible] = 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;
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [volumePanelOpen, setVolumePanelOpen] = useState(false);
|
|
const [keyFeedback, setKeyFeedback] = useState(null);
|
|
const [speed, setSpeed] = useState("Normal");
|
|
const [buffered, setBuffered] = useState(0);
|
|
const [buffering, setBuffering] = useState(false);
|
|
const [hoverProgress, setHoverProgress] = useState(null);
|
|
|
|
const hideTimer = useRef(null);
|
|
const keyFeedbackTimer = useRef(null);
|
|
const previewVidRef = useRef(null);
|
|
|
|
const assetId = content?.asset_id;
|
|
const storageProvider = content?.storage_provider;
|
|
// content.thumbnail_url is a presigned S3 URL captured at CMS pick time —
|
|
// it expires. For S3 assets, use the fresh thumbnail_url minted alongside
|
|
// the stream token below instead; only fall back to the persisted value
|
|
// for chibisafe (stable public CDN URL).
|
|
const poster = (storageProvider === "s3" ? freshThumb : content?.thumbnail_url) ?? undefined;
|
|
|
|
// ── Resolve stream URL → set as video src directly ───────────────────────
|
|
//
|
|
// Previously we fetched all bytes into a Blob and used URL.createObjectURL().
|
|
// That blob URL could be opened in a new tab and saved with "Save Video As...".
|
|
// Now we set the stream URL directly as <video src> — no blob is ever created.
|
|
// The backend blocks direct browser navigation (Sec-Fetch-Mode: navigate → 401)
|
|
// and the token is IP-bound, so sharing the URL is ineffective.
|
|
useEffect(() => {
|
|
if (!assetId) return;
|
|
|
|
setBlobUrl(null);
|
|
setFreshThumb(null);
|
|
setFetchError(false);
|
|
setPlaying(false);
|
|
setProgress(0);
|
|
setCurrentTime(0);
|
|
setTotalDuration(0);
|
|
setOverlayVisible(true);
|
|
setSettingsOpen(false);
|
|
setEnded(false);
|
|
guard.reset();
|
|
resumedRef.current = false;
|
|
|
|
let cancelled = false;
|
|
|
|
const load = async () => {
|
|
setFetchLoading(true);
|
|
try {
|
|
// Chibisafe — use raw URL directly
|
|
if (storageProvider === "chibisafe") {
|
|
const raw = content?.url;
|
|
if (!raw) throw new Error("No URL in content");
|
|
if (!cancelled) setBlobUrl(raw);
|
|
return;
|
|
}
|
|
|
|
// S3 — get token then stream directly; no blob download
|
|
const { data } = await api.post("/client/media/token", { asset_id: assetId });
|
|
if (cancelled) return;
|
|
const { token, thumbnail_url } = data?.data ?? {};
|
|
if (!token) throw new Error("No token returned");
|
|
|
|
if (!cancelled) {
|
|
setBlobUrl(`${API_BASE}/client/media/stream/${token}`);
|
|
if (thumbnail_url) setFreshThumb(thumbnail_url);
|
|
}
|
|
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
console.error("[VideoBlock] load failed", err);
|
|
setFetchError(true);
|
|
} finally {
|
|
if (!cancelled) setFetchLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
|
|
return () => { cancelled = true; };
|
|
}, [assetId, storageProvider]);
|
|
|
|
// ── Video events ──────────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
const v = vidRef.current;
|
|
if (!v || !blobUrl) return;
|
|
|
|
const onTimeUpdate = () => {
|
|
setCurrentTime(v.currentTime);
|
|
guard.trackTimeUpdate(v.currentTime, v.duration);
|
|
if (v.duration) {
|
|
const pct = (v.currentTime / v.duration) * 100;
|
|
setProgress(pct);
|
|
guard.maybeReport(pct);
|
|
}
|
|
};
|
|
const onLoaded = () => {
|
|
setTotalDuration(v.duration);
|
|
guard.setDuration(v.duration);
|
|
// Resume from the last position reached, once per asset. Left alone once
|
|
// fully watched (>=99%) — restarting reads better than resuming at the end.
|
|
if (!resumedRef.current && resumePercent > 0 && resumePercent < 99 && v.duration) {
|
|
resumedRef.current = true;
|
|
const resumeSeconds = Math.min((resumePercent / 100) * v.duration, v.duration - 0.25);
|
|
v.currentTime = resumeSeconds;
|
|
setCurrentTime(resumeSeconds);
|
|
setProgress(resumePercent);
|
|
// Seeds the seek-cap so forward-seeking within already-watched territory
|
|
// works immediately, instead of clamping back to 0 until the next tick.
|
|
guard.trackTimeUpdate(resumeSeconds, v.duration);
|
|
}
|
|
};
|
|
const onEnded = () => {
|
|
setPlaying(false); setOverlayVisible(false); setEnded(true);
|
|
onWatchProgress?.(100);
|
|
};
|
|
const onPlay = () => {
|
|
if (v.duration) guard.reportPlayStart((v.currentTime / v.duration) * 100);
|
|
};
|
|
const onPause = () => {
|
|
if (v.duration) guard.flush((v.currentTime / v.duration) * 100);
|
|
};
|
|
const onSeeking = () => guard.markSeeking();
|
|
const onSeeked = () => guard.markSeeked();
|
|
const onWaiting = () => setBuffering(true);
|
|
const onCanPlay = () => setBuffering(false);
|
|
const onProgress = () => {
|
|
if (v.buffered.length && v.duration) {
|
|
setBuffered((v.buffered.end(v.buffered.length - 1) / v.duration) * 100);
|
|
}
|
|
};
|
|
|
|
v.addEventListener("waiting", onWaiting);
|
|
v.addEventListener("canplay", onCanPlay);
|
|
v.addEventListener("timeupdate", onTimeUpdate);
|
|
v.addEventListener("loadedmetadata", onLoaded);
|
|
v.addEventListener("ended", onEnded);
|
|
v.addEventListener("play", onPlay);
|
|
v.addEventListener("pause", onPause);
|
|
v.addEventListener("seeking", onSeeking);
|
|
v.addEventListener("seeked", onSeeked);
|
|
v.addEventListener("progress", onProgress);
|
|
|
|
if (v.readyState >= 1 && v.duration) onLoaded();
|
|
|
|
return () => {
|
|
v.removeEventListener("waiting", onWaiting);
|
|
v.removeEventListener("canplay", onCanPlay);
|
|
v.removeEventListener("timeupdate", onTimeUpdate);
|
|
v.removeEventListener("loadedmetadata", onLoaded);
|
|
v.removeEventListener("ended", onEnded);
|
|
v.removeEventListener("play", onPlay);
|
|
v.removeEventListener("pause", onPause);
|
|
v.removeEventListener("seeking", onSeeking);
|
|
v.removeEventListener("seeked", onSeeked);
|
|
v.removeEventListener("progress", onProgress);
|
|
};
|
|
}, [blobUrl]);
|
|
|
|
useEffect(() => {
|
|
const v = vidRef.current;
|
|
if (!v) return;
|
|
v.playbackRate = speed === "Normal" ? 1 : parseFloat(speed);
|
|
}, [speed]);
|
|
|
|
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]);
|
|
|
|
const resetHideTimer = useCallback(() => {
|
|
setControlsVisible(true);
|
|
clearTimeout(hideTimer.current);
|
|
if (playing) {
|
|
hideTimer.current = setTimeout(() => {
|
|
setControlsVisible(false);
|
|
setSettingsOpen(false);
|
|
}, 3000);
|
|
}
|
|
}, [playing]);
|
|
|
|
useEffect(() => {
|
|
resetHideTimer();
|
|
return () => clearTimeout(hideTimer.current);
|
|
}, [playing, resetHideTimer]);
|
|
|
|
const showFeedback = useCallback((icon, label) => {
|
|
setKeyFeedback((prev) => ({ icon, label, key: (prev?.key ?? 0) + 1 }));
|
|
clearTimeout(keyFeedbackTimer.current);
|
|
keyFeedbackTimer.current = setTimeout(() => setKeyFeedback(null), 800);
|
|
}, []);
|
|
|
|
const togglePlay = useCallback(() => {
|
|
const v = vidRef.current;
|
|
if (!v) return;
|
|
if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); setEnded(false); }
|
|
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
|
|
}, []);
|
|
|
|
// YouTube-style tap-to-reveal: while playing with the bar auto-hidden, the
|
|
// first tap only brings the controls back (doesn't pause); a second tap —
|
|
// once controls are already visible — toggles play, same as desktop where
|
|
// onMouseMove already keeps controls visible ahead of the click.
|
|
const handleWrapperClick = useCallback(() => {
|
|
// Safari doesn't focus a <div tabIndex> on click, which silently kills
|
|
// keyboard shortcuts afterwards — force it explicitly.
|
|
wrapRef.current?.focus({ preventScroll: true });
|
|
if (!controlsVisible) { resetHideTimer(); return; }
|
|
togglePlay();
|
|
resetHideTimer();
|
|
}, [controlsVisible, resetHideTimer, 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 = guard.clampSeekTarget((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 = 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);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = useCallback((e) => {
|
|
if (e.target.tagName === "INPUT") return;
|
|
// 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 " ": case "k":
|
|
e.preventDefault();
|
|
togglePlay();
|
|
showFeedback(playing ? <Pause className="size-7 text-white" /> : <Play className="size-7 text-white" />, playing ? "Pause" : "Play");
|
|
resetHideTimer();
|
|
break;
|
|
case "ArrowRight":
|
|
e.preventDefault();
|
|
if (vidRef.current) vidRef.current.currentTime = guard.clampSeekTarget(Math.min(vidRef.current.currentTime + 5, vidRef.current.duration));
|
|
showFeedback(<SkipForward className="size-7 text-white" />, "+5s");
|
|
resetHideTimer();
|
|
break;
|
|
case "ArrowLeft":
|
|
e.preventDefault();
|
|
if (vidRef.current) vidRef.current.currentTime = Math.max(vidRef.current.currentTime - 5, 0);
|
|
showFeedback(<SkipBack className="size-7 text-white" />, "-5s");
|
|
resetHideTimer();
|
|
break;
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
if (vidRef.current) {
|
|
const nv = Math.min(volume + 0.1, 1);
|
|
vidRef.current.volume = nv; setVolume(nv); setMuted(false);
|
|
showFeedback(<Volume2 className="size-7 text-white" />, `${Math.round(nv * 100)}%`);
|
|
}
|
|
break;
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
if (vidRef.current) {
|
|
const nv = Math.max(volume - 0.1, 0);
|
|
vidRef.current.volume = nv; setVolume(nv); setMuted(nv === 0);
|
|
showFeedback(<Volume1 className="size-7 text-white" />, `${Math.round(nv * 100)}%`);
|
|
}
|
|
break;
|
|
case "m":
|
|
e.preventDefault();
|
|
toggleMute();
|
|
showFeedback(muted ? <Volume2 className="size-7 text-white" /> : <VolumeX className="size-7 text-white" />, muted ? "Unmuted" : "Muted");
|
|
break;
|
|
case "f":
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
showFeedback(isFullscreen ? <Minimize2 className="size-7 text-white" /> : <Maximize2 className="size-7 text-white" />, isFullscreen ? "Exit fullscreen" : "Fullscreen");
|
|
break;
|
|
case "Escape":
|
|
if (isFullscreen) {
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
showFeedback(<Minimize2 className="size-7 text-white" />, "Exit fullscreen");
|
|
}
|
|
break;
|
|
default: break;
|
|
}
|
|
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback, guard]);
|
|
|
|
// ── States ────────────────────────────────────────────────────────────────
|
|
|
|
if (!assetId) {
|
|
return (
|
|
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
|
<VideoIcon className="h-4 w-4" /> No video
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (fetchLoading) {
|
|
return <MediaFallback className="aspect-video rounded-lg" />;
|
|
}
|
|
|
|
if (fetchError || !blobUrl) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center aspect-video rounded-lg bg-black/80 gap-2">
|
|
<VideoIcon className="h-8 w-8 text-white/30" />
|
|
<p className="text-sm text-white/50">Video unavailable.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={400}>
|
|
<div
|
|
ref={wrapRef}
|
|
tabIndex={0}
|
|
className={`relative w-full overflow-hidden bg-black select-none outline-none ${pseudoFullscreen ? "fixed inset-0 z-[100]" : "rounded-lg"}`}
|
|
style={{ aspectRatio: isFullscreen ? undefined : "16/9" }}
|
|
onMouseMove={resetHideTimer}
|
|
onMouseLeave={() => { if (playing) setControlsVisible(false); }}
|
|
onClick={handleWrapperClick}
|
|
onKeyDown={handleKeyDown}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
>
|
|
<video
|
|
ref={vidRef}
|
|
src={blobUrl}
|
|
poster={poster}
|
|
preload="auto"
|
|
playsInline
|
|
controlsList="nodownload nofullscreen noremoteplayback"
|
|
disablePictureInPicture
|
|
disableRemotePlayback
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
className={`w-full h-full ${isFullscreen ? "object-contain" : "object-cover"}`}
|
|
/>
|
|
|
|
{/* Centre overlay */}
|
|
<div
|
|
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 pointer-events-none ${overlayVisible ? "opacity-100" : "opacity-0"}`}
|
|
style={{ background: "rgba(0,0,0,0.3)" }}
|
|
>
|
|
<div className="w-14 h-14 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center border border-white/25">
|
|
{playing ? <Pause className="size-6 text-white" /> : <Play className="size-6 text-white ml-0.5" />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Buffering spinner */}
|
|
{buffering && (
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div className="w-12 h-12 rounded-full border-4 border-white/20 border-t-white animate-spin" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Keyboard feedback */}
|
|
{keyFeedback && (
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div
|
|
key={keyFeedback.key}
|
|
className="flex flex-col items-center gap-1 px-5 py-3 rounded-2xl border border-white/10"
|
|
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(12px)", animation: "fadeInOut 0.8s ease forwards" }}
|
|
>
|
|
<span className="leading-none">{keyFeedback.icon}</span>
|
|
<span className="text-white text-sm font-medium tracking-wide">{keyFeedback.label}</span>
|
|
</div>
|
|
<style>{`@keyframes fadeInOut{0%{opacity:0;transform:scale(.85)}20%{opacity:1;transform:scale(1)}70%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(.95)}}`}</style>
|
|
</div>
|
|
)}
|
|
|
|
{/* End overlay */}
|
|
{ended && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none" style={{ background: "rgba(0,0,0,0.55)" }}>
|
|
<button
|
|
type="button" aria-label="Replay"
|
|
className="pointer-events-auto w-16 h-16 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm border border-white/25 flex items-center justify-center transition-colors"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const v = vidRef.current;
|
|
if (!v) return;
|
|
v.currentTime = 0; v.play();
|
|
setPlaying(true); setEnded(false); setOverlayVisible(false);
|
|
}}
|
|
>
|
|
<RotateCcw className="size-7 text-white" />
|
|
</button>
|
|
<span className="text-white/70 text-sm">Replay</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Settings panel */}
|
|
{settingsOpen && (
|
|
<SettingsPanel speed={speed} onSpeed={setSpeed} onClose={() => setSettingsOpen(false)} />
|
|
)}
|
|
|
|
{/* Controls bar */}
|
|
<div
|
|
className={`absolute bottom-0 left-0 right-0 transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
|
style={{ background: "linear-gradient(to top, rgba(0,0,0,0.98) 0%, rgba(0,0,0,0.4) 80%, transparent 100%)" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="px-3 pb-4">
|
|
<div
|
|
className="relative lg:h-2 xs:h-1 group 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, pct: pct * 100, time });
|
|
if (previewVidRef.current && isFinite(time) && time >= 0) {
|
|
previewVidRef.current.currentTime = time;
|
|
}
|
|
}}
|
|
onMouseLeave={() => setHoverProgress(null)}
|
|
>
|
|
<div className="absolute inset-0 bg-white/25 rounded-full" />
|
|
<div className="absolute inset-y-0 left-0 bg-white/40 rounded-full transition-[width] duration-300" style={{ width: `${buffered}%` }} />
|
|
<div className="absolute inset-y-0 left-0 bg-white rounded-full" style={{ width: `${progress}%` }} />
|
|
<div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow-md -ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${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" />
|
|
|
|
{/* Scrubber preview — also uses blob URL */}
|
|
{hoverProgress && (
|
|
<div
|
|
className="hidden lg:flex absolute bottom-5 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" style={{ width: 160, height: 90 }}>
|
|
<video ref={previewVidRef} src={blobUrl} 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">{fmtTime(hoverProgress.time)}</span>
|
|
<div className="w-2 h-2 bg-black/60 rotate-45 -mt-1 border-r border-b border-white/20" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 px-2.5 pb-3">
|
|
<CtrlBtn label={playing ? "Pause" : "Play"} onClick={togglePlay}>
|
|
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
|
</CtrlBtn>
|
|
<CtrlBtn label="Restart" onClick={restart}>
|
|
<SkipBack className="size-4" />
|
|
</CtrlBtn>
|
|
|
|
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
|
{volumePanelOpen && (
|
|
<div
|
|
className="lg:hidden absolute bottom-9 left-1/2 -translate-x-1/2 z-30 rounded-xl border border-white/10 px-4 py-3 flex flex-col items-center gap-2"
|
|
style={{ background: "rgba(18,18,18,0.96)", backdropFilter: "blur(16px)" }}
|
|
>
|
|
<span className="text-[10px] text-white/40 uppercase tracking-widest">Volume</span>
|
|
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolumeChange} aria-label="Volume" className="accent-white cursor-pointer" style={{ writingMode: "vertical-lr", direction: "rtl", height: "80px", width: "auto" }} />
|
|
<span className="text-xs text-white/50 tabular-nums">{muted ? "0" : Math.round(volume * 100)}%</span>
|
|
</div>
|
|
)}
|
|
<CtrlBtn
|
|
label={muted ? "Unmute" : "Mute"}
|
|
onClick={() => {
|
|
if (window.innerWidth < 1024) setVolumePanelOpen((o) => !o);
|
|
else toggleMute();
|
|
}}
|
|
>
|
|
{muted ? <VolumeX className="size-4 sm:size-5" /> : <Volume2 className="size-4 sm:size-5" />}
|
|
</CtrlBtn>
|
|
</div>
|
|
|
|
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolumeChange} aria-label="Volume" className="hidden lg:block w-16 accent-white cursor-pointer" onClick={(e) => e.stopPropagation()} />
|
|
|
|
<span className="text-white/60 text-sm tabular-nums ml-1.5">
|
|
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
|
|
</span>
|
|
|
|
<div className="ml-auto flex items-center gap-3">
|
|
{speed !== "Normal" && (
|
|
<span className="text-sm text-white/60 bg-white/10 px-1.5 py-0.5 rounded-sm font-mono">{speed}x</span>
|
|
)}
|
|
<CtrlBtn label="Settings" onClick={(e) => { e.stopPropagation(); setSettingsOpen((o) => !o); }} className={settingsOpen ? "text-white" : ""}>
|
|
<Settings className={`size-5 transition-transform duration-300 ${settingsOpen ? "rotate-45" : ""}`} />
|
|
</CtrlBtn>
|
|
<CtrlBtn label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} onClick={toggleFullscreen}>
|
|
{isFullscreen ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
|
|
</CtrlBtn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
} |