import { useRef, useState, useEffect, useCallback } from "react"; import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2, } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { AssetPickerSheet } from "../../AssetPickerSheet"; import api from "@/utils/api.util"; import { MediaFallback } from "@/components/generic/MediaFallback"; 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) ─────────────────────────────────────────────────────── export function AudioBlock({ content, onUpdate, readOnly = false }) { const [pickerOpen, setPickerOpen] = useState(false); // ── S3 token state — fetched when storage_provider is "s3" ─────────────── const [streamSrc, setStreamSrc] = useState(null); const [streamThumb, setStreamThumb] = useState(null); const [tokenLoading, setTokenLoading] = useState(false); const audioRef = useRef(null); const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [buffered, setBuffered] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(1); const [muted, setMuted] = useState(false); const [speedIdx, setSpeedIdx] = useState(2); // 1× const assetId = content.asset_id ?? null; const storageProvider = content.storage_provider ?? null; const isS3 = storageProvider === "s3"; // For S3 assets, use the stream URL fetched via admin token. // For all other providers, use the raw url/src from block content. const src = isS3 ? (streamSrc ?? "") : (content.url ?? content.src ?? ""); const title = content.title ?? "Audio"; const artist = content.artist ?? ""; const tag = content.tag ?? ""; const thumbnail = isS3 ? (streamThumb ?? content.thumbnail ?? null) : (content.thumbnail ?? null); // ── Fetch admin token for S3 assets ─────────────────────────────────────── useEffect(() => { if (!assetId || !isS3) { setStreamSrc(null); setStreamThumb(null); return; } let cancelled = false; setTokenLoading(true); api.post("/admin/media/token", { asset_id: assetId }) .then(({ data }) => { if (cancelled) return; const { token, thumbnail_url } = data?.data ?? {}; if (token) setStreamSrc(`${API_BASE}/client/media/stream/${token}`); if (thumbnail_url) setStreamThumb(thumbnail_url); }) .catch(() => { /* non-fatal — player shows nothing */ }) .finally(() => { if (!cancelled) setTokenLoading(false); }); return () => { cancelled = true; }; }, [assetId, isS3]); // ── Audio events ────────────────────────────────────────────────────────── const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []); const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []); const onEnded = useCallback(() => setPlaying(false), []); const onProgress = useCallback(() => { const el = audioRef.current; if (el?.buffered.length && el.duration) { setBuffered((el.buffered.end(el.buffered.length - 1) / el.duration) * 100); } }, []); // ── Controls ────────────────────────────────────────────────────────────── const togglePlay = () => { const el = audioRef.current; if (!el) return; if (playing) { el.pause(); setPlaying(false); } else { el.play(); setPlaying(true); } }; const seek = (e) => { const el = audioRef.current; const bar = e.currentTarget; const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth; el.currentTime = pct * duration; }; const skip = (secs) => { const el = audioRef.current; if (!el) return; el.currentTime = Math.min(Math.max(0, el.currentTime + secs), duration); }; const handleVolume = (e) => { const v = parseFloat(e.target.value); setVolume(v); if (audioRef.current) audioRef.current.volume = v; setMuted(v === 0); }; const toggleMute = () => { const el = audioRef.current; if (!el) return; el.muted = !muted; setMuted(!muted); }; const cycleSpeed = () => { const next = (speedIdx + 1) % SPEEDS.length; setSpeedIdx(next); if (audioRef.current) audioRef.current.playbackRate = SPEEDS[next]; }; const progress = duration > 0 ? (currentTime / duration) * 100 : 0; // ── Asset picker handler ────────────────────────────────────────────────── // // Stores all metadata needed by the client AudioBlock at render time so // the client never needs a separate API call to fetch asset details. // // Fields saved to block content: // asset_id — used by client for the secure token flow // url — used by admin player (direct src); ignored by client for S3 // title — display name (kept if already customised, else asset name) // artist — cleared on new pick so stale artist doesn't carry over // thumbnail — cover art from asset.thumbnail_url // tag — file extension badge e.g. "MP3" // const handleSelect = (asset) => { onUpdate({ asset_id: asset.asset_id, // url is null for S3 (redacted server-side); Chibisafe/CDN keeps its raw URL url: asset.file_url ?? null, storage_provider: asset.storage_provider ?? null, title: asset.display_name, artist: "", thumbnail: asset.thumbnail_url ?? null, tag: asset.extension?.toUpperCase() ?? "", }); setPlaying(false); setCurrentTime(0); setDuration(0); setBuffered(0); setStreamSrc(null); setStreamThumb(null); }; // ───────────────────────────────────────────────────────────────────────── return (
{title}
{artist &&{artist}
}