mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
391 lines
19 KiB
React
391 lines
19 KiB
React
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>
|
||
);
|
||
} |