mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── 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];
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
|
||||
// ─── AudioBlock (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. <audio src="blob:..."> → real URL never visible in DOM
|
||||
//
|
||||
// Chibisafe:
|
||||
// → content.url used directly (Chibisafe CDN, no proxy needed)
|
||||
//
|
||||
// Direct (legacy):
|
||||
// → content.url / content.src used directly, no token flow
|
||||
//
|
||||
// content shape: { asset_id?, url?, storage_provider?, title?, artist?, tag?, thumbnail? }
|
||||
|
||||
export function AudioBlock({ content }) {
|
||||
const audioRef = useRef(null);
|
||||
|
||||
// ── Stream state ──────────────────────────────────────────────────────────
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState(null);
|
||||
const [fetchLoading, setFetchLoading] = useState(false);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
|
||||
// ── Player state ──────────────────────────────────────────────────────────
|
||||
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;
|
||||
const storageProvider = content?.storage_provider;
|
||||
const directUrl = content?.url ?? content?.src ?? null;
|
||||
const title = content?.title ?? "Audio";
|
||||
const artist = content?.artist ?? "";
|
||||
const tag = content?.tag ?? "";
|
||||
// For S3 assets, thumbnailUrl is set from the token response (presigned URL).
|
||||
// For other providers, fall back to the raw content.thumbnail value.
|
||||
const thumbnail = thumbnailUrl ?? content?.thumbnail ?? null;
|
||||
|
||||
// ── Resolve stream URL → set as audio src directly ───────────────────────
|
||||
useEffect(() => {
|
||||
setBlobUrl(null);
|
||||
setThumbnailUrl(null);
|
||||
setFetchError(false);
|
||||
setPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
|
||||
// Legacy direct URL — no asset_id
|
||||
if (!assetId && directUrl) {
|
||||
setBlobUrl(directUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assetId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setFetchLoading(true);
|
||||
try {
|
||||
// Chibisafe — use raw URL directly (CDN is public, no token needed)
|
||||
if (storageProvider === "chibisafe") {
|
||||
if (!directUrl) throw new Error("No URL in content");
|
||||
if (!cancelled) setBlobUrl(directUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// S3 — get token; response also includes presigned thumbnail URL
|
||||
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) setThumbnailUrl(thumbnail_url);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error("[AudioBlock] load failed", err);
|
||||
setFetchError(true);
|
||||
} finally {
|
||||
if (!cancelled) setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [assetId, storageProvider, directUrl]);
|
||||
|
||||
// ── 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;
|
||||
|
||||
// ── States ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="w-full rounded-xl border border-border bg-card flex items-center justify-center h-28">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="w-full rounded-xl border border-border bg-card flex items-center justify-center h-24 gap-2 text-muted-foreground text-sm">
|
||||
<Music2 className="size-4" /> Audio unavailable.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blobUrl) return null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="w-full rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={blobUrl}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMeta}
|
||||
onEnded={onEnded}
|
||||
onProgress={onProgress}
|
||||
preload="auto"
|
||||
/>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="xs:opacity-0 lg:opacity-100 select-none absolute top-4 right-5 z-20">
|
||||
<img src="/philpro-white-single.png" alt="Logo" className="h-6 w-auto" />
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
)}
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="relative z-10 flex flex-col gap-4 pb-6 text-white sm:hidden">
|
||||
<div className="w-full px-4 pt-4">
|
||||
<div className="w-full h-72 aspect-square rounded-lg overflow-hidden bg-black/30 shadow-xl dark:border">
|
||||
{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-10 h-10 text-white/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-4">
|
||||
{tag && (
|
||||
<div className="text-xs font-semibold rounded-full uppercase px-3 py-1 bg-card text-card-foreground border w-fit mb-1">
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-lg font-semibold leading-tight">{title}</p>
|
||||
{artist && <p className="text-sm text-white/60">{artist}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="relative z-10 hidden sm:flex items-center gap-4 p-4 text-white">
|
||||
<div className="shrink-0 w-48 h-48 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 min-w-0">
|
||||
{tag && (
|
||||
<div className="text-xs font-semibold rounded-full uppercase px-3 py-1 bg-card text-card-foreground border w-fit">
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<p className="w-sm line-clamp-3 text-lg font-semibold leading-tight">{title}</p>
|
||||
{artist && <p className="text-sm text-white/60 truncate">{artist}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Controls ── */}
|
||||
<div className="px-4 pb-4 pt-3 space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs tabular-nums text-card-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-card-foreground w-8 shrink-0 text-right">
|
||||
{fmtTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 items-center">
|
||||
{/* Left — 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>
|
||||
|
||||
{/* Center — play controls */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button onClick={() => skip(-10)} aria-label="Rewind 10 seconds" 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="xs:size-4 lg:size-5" /> : <Play className="xs:size-4 lg:size-5" />}
|
||||
</button>
|
||||
<button onClick={() => skip(10)} aria-label="Forward 10 seconds" className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
|
||||
<RotateCw className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right — speed */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={cycleSpeed} aria-label={`Playback 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user