assets and tier plans revamp

This commit is contained in:
rgrgogu
2026-08-01 17:44:12 +08:00
parent 4f738a691d
commit a34c6feb84
45 changed files with 2172 additions and 273 deletions
@@ -13,18 +13,11 @@ import { Textarea } from "@/components/ui/textarea";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { Spinner } from "@/components/ui/spinner";
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// ─── 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}`;
};
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
// ─── AudioBlock (Admin) ───────────────────────────────────────────────────────
@@ -45,6 +38,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [speedIdx, setSpeedIdx] = useState(2); // 1×
// True from the moment `src` is set until the browser has actually
// buffered enough to play (or stalls mid-playback) — same gap VideoBlock
// closes, so a slow-loading audio file doesn't just sit there silently.
const [mediaLoading, setMediaLoading] = useState(true);
const assetId = content.asset_id ?? null;
const storageProvider = content.storage_provider ?? null;
@@ -79,6 +76,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
return () => { cancelled = true; };
}, [assetId, isS3]);
// Reset the loading spinner whenever the src actually changes (new
// asset picked, or the S3 token above just resolved).
useEffect(() => { setMediaLoading(true); }, [src]);
// ── Audio events ──────────────────────────────────────────────────────────
const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []);
@@ -90,6 +91,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
setBuffered((el.buffered.end(el.buffered.length - 1) / el.duration) * 100);
}
}, []);
const onLoadedData = useCallback(() => setMediaLoading(false), []);
const onCanPlay = useCallback(() => setMediaLoading(false), []);
const onWaiting = useCallback(() => setMediaLoading(true), []);
const onPlaying = useCallback(() => setMediaLoading(false), []);
// ── Controls ──────────────────────────────────────────────────────────────
@@ -186,6 +191,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
onLoadedMetadata={onLoadedMeta}
onEnded={onEnded}
onProgress={onProgress}
onLoadedData={onLoadedData}
onCanPlay={onCanPlay}
onWaiting={onWaiting}
onPlaying={onPlaying}
preload="metadata"
/>
@@ -206,13 +215,13 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
)}
<div className="relative z-10 flex items-center gap-4 p-4 text-white">
<div className="shrink-0 w-20 h-20 rounded-md overflow-hidden bg-black/25">
{thumbnail ? (
<div className="shrink-0 w-20 h-20 rounded-md overflow-hidden bg-black/25 flex items-center justify-center">
{mediaLoading ? (
<Spinner className="size-6 text-white" />
) : thumbnail ? (
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Music2 className="w-7 h-7 text-white/30" />
</div>
<Music2 className="w-7 h-7 text-white/30" />
)}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
@@ -12,15 +12,8 @@ import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
// ─── 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}`;
};
import { Spinner } from "@/components/ui/spinner";
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
@@ -44,6 +37,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [overlayVisible,setOverlayVisible]= useState(true);
// 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);
// Reset player when video changes
useEffect(() => {
@@ -52,6 +52,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
setMediaLoading(true);
}, [src]);
// ── Video event listeners ─────────────────────────────────────────────────
@@ -64,17 +65,29 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
setCurrentTime(v.currentTime);
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
};
const onLoaded = () => setTotalDuration(v.duration);
const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
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]);
@@ -180,22 +193,32 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
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 */}
<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"
{!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)" }}
>
{playing
? <Pause className="size-4 text-black" />
: <Play className="size-4 text-black ml-0.5" />
}
</button>
</div>
<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 && (
@@ -3,15 +3,7 @@ import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lu
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
// ─── 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}`;
};
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
@@ -10,15 +10,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
// ─── 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}`;
};
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];