Files
starr-philproperties/src/components/generic/Blocks/Admin/AudioBlock.jsx
T
kennethobsequio 7e964f2432 add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-03 16:21:27 +08:00

391 lines
19 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="space-y-3">
{!readOnly && <Label>Audio</Label>}
{isS3 && tokenLoading ? (
<MediaFallback className="w-full max-w-lg h-28 rounded-xl border border-border" />
) : src ? (
<div className="w-full max-w-lg rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
<audio
ref={audioRef}
src={src}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMeta}
onEnded={onEnded}
onProgress={onProgress}
preload="metadata"
/>
{/* ── Header ── */}
<div className="relative overflow-hidden">
{thumbnail ? (
<div
className="absolute inset-0 scale-110"
style={{
backgroundImage: `url(${thumbnail})`,
backgroundSize: "cover",
backgroundPosition: "center",
filter: "blur(24px) brightness(0.35)",
}}
/>
) : (
<div className="absolute inset-0 bg-muted" />
)}
<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 ? (
<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>
)}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
{tag && (
<div className="text-xs font-semibold rounded-full uppercase px-2 py-0.5 bg-card text-card-foreground border w-fit">
{tag}
</div>
)}
<p className="text-sm font-semibold leading-snug line-clamp-3">{title}</p>
{artist && <p className="text-xs text-white/60 line-clamp-2">{artist}</p>}
</div>
</div>
</div>
{/* ── Controls ── */}
<div className="px-4 pb-4 pt-3 space-y-3">
{/* Progress bar */}
<div className="flex items-center gap-2.5">
<span className="text-xs tabular-nums text-muted-foreground w-8 shrink-0">
{fmtTime(currentTime)}
</span>
<div
className="flex-1 h-1.5 rounded-full bg-muted cursor-pointer relative group"
onClick={seek}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="absolute inset-y-0 left-0 rounded-full bg-muted-foreground/25 transition-[width] duration-300"
style={{ width: `${buffered}%` }}
/>
<div
className="h-full rounded-full bg-primary transition-all relative"
style={{ width: `${progress}%` }}
/>
<div
className="absolute top-1/2 w-3 h-3 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity"
style={{ left: `${progress}%`, transform: "translate(-50%, -50%)" }}
/>
</div>
<span className="text-xs tabular-nums text-muted-foreground w-8 shrink-0 text-right">
{fmtTime(duration)}
</span>
</div>
{/* Button row */}
<div className="grid grid-cols-3 items-center">
{/* Volume */}
<div className="flex items-center gap-1.5">
<button
onClick={toggleMute}
aria-label={muted ? "Unmute" : "Mute"}
className="w-7 h-7 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors"
>
{muted || volume === 0
? <VolumeOff className="size-4" />
: <Volume2 className="size-4" />
}
</button>
<input
type="range" min="0" max="1" step="0.05"
value={muted ? 0 : volume}
onChange={handleVolume}
aria-label="Volume"
className="w-16 h-1.5"
/>
</div>
{/* Play controls */}
<div className="flex items-center justify-center gap-2">
<button
onClick={() => skip(-10)}
aria-label="Rewind 10s"
className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors"
>
<RotateCcw className="size-4" />
</button>
<button
onClick={togglePlay}
aria-label={playing ? "Pause" : "Play"}
className="p-3 rounded-full flex items-center justify-center bg-primary text-primary-foreground hover:opacity-80 transition-opacity active:scale-95 shadow-md"
>
{playing
? <Pause className="size-4" />
: <Play className="size-4" />
}
</button>
<button
onClick={() => skip(10)}
aria-label="Forward 10s"
className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors"
>
<RotateCw className="size-4" />
</button>
</div>
{/* Speed */}
<div className="flex items-center justify-end">
<button
onClick={cycleSpeed}
aria-label={`Speed ${SPEEDS[speedIdx]}x`}
className="h-7 px-2 rounded text-sm font-medium text-foreground hover:bg-muted transition-colors tabular-nums"
>
{SPEEDS[speedIdx]}x
</button>
</div>
</div>
</div>
{/* ── Change audio (admin only) ── */}
{!readOnly && (
<div className="px-4 pb-4">
<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 audio
</button>
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => !readOnly && setPickerOpen(true)}
className="w-full py-12 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"
>
<Music2 className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select an audio file</p>
</button>
)}
{/* ── Metadata fields (admin only) ─────────────────────────────────
These fields are saved into block content and rendered directly
by the client AudioBlock — no extra API call needed at runtime. */}
{!readOnly && src && (
<div className="space-y-3 pt-1">
<div className="space-y-1.5">
<Label htmlFor="audio-title">Title</Label>
<Textarea
id="audio-title"
rows={2}
value={content.title ?? ""}
onChange={(e) => onUpdate({ ...content, title: e.target.value })}
placeholder="Track title"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audio-artist">Artist / Subtitle</Label>
<Textarea
id="audio-artist"
rows={2}
value={content.artist ?? ""}
onChange={(e) => onUpdate({ ...content, artist: e.target.value })}
placeholder="Artist name or subtitle"
/>
</div>
</div>
)}
{/* ── Asset picker ── */}
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="audio"
onSelect={handleSelect}
/>
)}
</div>
);
}