Files
starr-philproperties/src/components/generic/Blocks/Client/AudioBlock.jsx
T

345 lines
16 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 api from "@/utils/api.util";
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}`;
};
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(/\/$/, "");
// How often onWatchProgress may fire while playing (ms) — keeps the watch-progress
// endpoint from getting hit on every timeupdate tick. onEnded still always reports
// a final 100% immediately regardless of this window, so completion never lags.
const WATCH_PROGRESS_THROTTLE_MS = 10000;
// ─── 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? }
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
// immediately at 100% on end. Backing the watch_percent completion requirement type.
export function AudioBlock({ content, onWatchProgress }) {
const audioRef = useRef(null);
const lastReportRef = useRef(0);
// ── 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(() => {
const el = audioRef.current;
setCurrentTime(el?.currentTime ?? 0);
if (onWatchProgress && el?.duration) {
const pct = (el.currentTime / el.duration) * 100;
const now = Date.now();
if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) {
lastReportRef.current = now;
onWatchProgress(pct);
}
}
}, [onWatchProgress]);
const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []);
const onEnded = useCallback(() => { setPlaying(false); onWatchProgress?.(100); }, [onWatchProgress]);
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 <MediaFallback className="w-full h-28 rounded-xl border border-border" />;
}
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>
);
}