feat(fe): recordings — visible playing/loading/paused states

- recording-card: kartu aktif di-highlight (ring primary + glow pulse),
  badge 'Now Playing'/'Loading'/'Paused', tombol play berubah jadi Pause
  saat playing dan spinner saat loading, waveform equalizer beranimasi
  (animate-eq, delay per bar) saat playing / pulse saat loading.
- recording-player: jadi now-playing panel — tombol play/pause + spinner
  loading, progress bar + waktu (current/duration), status 'loading…',
  audio element pindah ke sini + event onPlay/onPause/onWaiting/onCanPlay/
  onPlaying/onError naik ke page.
- recordings/page: state isPlaying/isLoadingAudio + audioRef, togglePlay
  (klik card lain = ganti track, klik card sama = pause/resume).
- globals.css: keyframes eq-bounce + card-glow.

Verified: FE tsc0, next build 10/10 static pages.
This commit is contained in:
asepharyana
2026-08-01 16:54:58 +07:00
parent 0daee56213
commit 98064d1dd9
4 changed files with 241 additions and 46 deletions
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Clock, Database, Mic, Users } from "lucide-react"; import { Clock, Database, Mic, Users } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { StatCard } from "@/components/dashboard/stat-card"; import { StatCard } from "@/components/dashboard/stat-card";
import { SubNav } from "@/components/layout/sub-nav"; import { SubNav } from "@/components/layout/sub-nav";
import { RecordingCard } from "@/components/recordings/recording-card"; import { RecordingCard } from "@/components/recordings/recording-card";
@@ -22,8 +22,11 @@ export default function RecordingsPage() {
mutate: refetch, mutate: refetch,
} = useRecordings(); } = useRecordings();
const [playingId, setPlayingId] = useState<string | null>(null); const [playingId, setPlayingId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
const [tab, setTab] = useState<RecordingsTab>("library"); const [tab, setTab] = useState<RecordingsTab>("library");
const ws = useWebSocket(); const ws = useWebSocket();
const audioRef = useRef<HTMLAudioElement | null>(null);
// Live-update the library when the gateway publishes voice_recording_uploaded // Live-update the library when the gateway publishes voice_recording_uploaded
useRecordingsWsSync(ws); useRecordingsWsSync(ws);
@@ -33,6 +36,17 @@ export default function RecordingsPage() {
? recordings.find((r: VoiceRecording) => r.id === playingId) ? recordings.find((r: VoiceRecording) => r.id === playingId)
: null; : null;
const togglePlay = (id: string) => {
if (playingId !== id) {
setPlayingId(id); // RecordingPlayer picks up the new url + autoplays
} else {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
};
const stats = useMemo(() => { const stats = useMemo(() => {
const list = recordings ?? []; const list = recordings ?? [];
const totalSize = list.reduce((sum, r) => sum + (r.size_bytes ?? 0), 0); const totalSize = list.reduce((sum, r) => sum + (r.size_bytes ?? 0), 0);
@@ -80,7 +94,10 @@ export default function RecordingsPage() {
<RecordingCard <RecordingCard
key={rec.id} key={rec.id}
recording={rec} recording={rec}
onPlay={(id) => setPlayingId(id === playingId ? null : id)} active={playingId === rec.id}
playing={playingId === rec.id && isPlaying}
loading={playingId === rec.id && isLoadingAudio}
onTogglePlay={togglePlay}
/> />
))} ))}
{(recordings ?? []).length === 0 && ( {(recordings ?? []).length === 0 && (
@@ -154,6 +171,14 @@ export default function RecordingsPage() {
<RecordingPlayer <RecordingPlayer
url={currentTrack?.download_url ?? undefined} url={currentTrack?.download_url ?? undefined}
filename={currentTrack?.filename ?? undefined} filename={currentTrack?.filename ?? undefined}
playing={isPlaying}
loading={isLoadingAudio}
audioRef={audioRef}
onToggle={() => togglePlay(playingId!)}
onStateChange={(s) => {
setIsPlaying(s.playing);
setIsLoadingAudio(s.loading);
}}
onClose={() => setPlayingId(null)} onClose={() => setPlayingId(null)}
/> />
</div> </div>
+32
View File
@@ -167,6 +167,38 @@
animation: shimmer 1.5s infinite; animation: shimmer 1.5s infinite;
} }
/* Equalizer bars — bouncing heights for the "now playing" waveform */
@keyframes eq-bounce {
0%,
100% {
transform: scaleY(0.25);
}
30% {
transform: scaleY(1);
}
60% {
transform: scaleY(0.5);
}
}
.animate-eq {
animation: eq-bounce 0.9s ease-in-out infinite;
transform-origin: bottom;
}
/* Soft pulsing glow for the active/loading card */
@keyframes card-glow {
0%,
100% {
box-shadow: 0 0 0 0 oklch(0.62 0.17 215 / 0);
}
50% {
box-shadow: 0 0 22px 0 oklch(0.62 0.17 215 / 0.25);
}
}
.animate-card-glow {
animation: card-glow 1.8s ease-in-out infinite;
}
/* ── Utility classes ─────────────────────── */ /* ── Utility classes ─────────────────────── */
/* Gradient text */ /* Gradient text */
@@ -1,16 +1,28 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Download, Loader2, Play } from "lucide-react"; import { Download, Loader2, Pause, Play } from "lucide-react";
import { GlassCard } from "@/components/glass/card"; import { GlassCard } from "@/components/glass/card";
import type { VoiceRecording } from "@/lib/types"; import type { VoiceRecording } from "@/lib/types";
interface RecordingCardProps { interface RecordingCardProps {
recording: VoiceRecording; recording: VoiceRecording;
onPlay: (id: string) => void; active: boolean;
playing: boolean;
loading: boolean;
onTogglePlay: (id: string) => void;
} }
export function RecordingCard({ recording, onPlay }: RecordingCardProps) { const BAR_COUNT = 40;
const barBase = (i: number) => 22 + Math.sin(i * 0.45) * 14 + ((i * 7) % 11);
export function RecordingCard({
recording,
active,
playing,
loading,
onTogglePlay,
}: RecordingCardProps) {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const durationStr = recording.duration_bytes const durationStr = recording.duration_bytes
? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}` ? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}`
@@ -42,50 +54,94 @@ export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
}; };
return ( return (
<GlassCard variant="interactive" className="p-4" onClick={() => onPlay(recording.id)}> <GlassCard
variant="interactive"
className={`p-4 transition-all ${
active
? "ring-1 ring-primary/40 border-primary/30 animate-card-glow"
: "hover:ring-1 hover:ring-border/60"
}`}
onClick={() => onTogglePlay(recording.id)}
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); onPlay(recording.id); }} onClick={(e) => {
className="size-10 flex items-center justify-center rounded-full glass-elevated shrink-0 hover:scale-105 transition-transform" e.stopPropagation();
onTogglePlay(recording.id);
}}
aria-label={playing ? "Pause" : loading ? "Loading" : "Play"}
className={`flex size-10 shrink-0 items-center justify-center rounded-full glass-elevated transition-transform hover:scale-105 ${
active ? "ring-1 ring-primary/50" : ""
}`}
> >
<Play className="size-4 text-primary ml-0.5" /> {loading ? (
<Loader2 className="size-4 animate-spin text-primary" />
) : playing ? (
<Pause className="size-4 text-primary" />
) : (
<Play className="size-4 text-primary ml-0.5" />
)}
</button> </button>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="font-semibold text-text-primary">{recording.username}</span> <span className="font-semibold text-text-primary">
<span className="text-[10px] text-text-secondary/40 font-mono">{recording.channel_name}</span> {recording.username}
</span>
<span className="text-[10px] text-text-secondary/40 font-mono">
{recording.channel_name}
</span>
{active && (
<span className="ml-auto inline-flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest text-primary/90">
{loading
? "Loading"
: playing
? "Now Playing"
: "Paused"}
</span>
)}
</div> </div>
{/* Mini waveform bar */} {/* Waveform — bounces while playing, pulses while loading */}
<div className="flex items-end gap-0.5 h-8 my-2"> <div className="my-2 flex h-8 items-end gap-0.5 overflow-hidden">
{Array.from({ length: 40 }, (_, i) => ( {Array.from({ length: BAR_COUNT }, (_, i) => (
<div <div
key={i} key={i}
className="flex-1 rounded-t-sm bg-primary/60" className={`flex-1 rounded-t-sm transition-colors ${
style={{ height: `${20 + Math.sin(i * 0.5) * 15 + Math.random() * 10}%` }} active ? "bg-primary" : "bg-primary/50"
} ${loading ? "animate-pulse opacity-40" : ""} ${
playing ? "animate-eq" : ""
}`}
style={{
height: `${barBase(i)}%`,
animationDelay: playing ? `${(i % 8) * 0.09}s` : undefined,
}}
/> />
))} ))}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-mono text-text-secondary/60">{durationStr}</span> <span className="text-[10px] font-mono text-text-secondary/60">
<span className="text-[10px] text-text-secondary/40">{new Date(recording.created_at).toLocaleString()}</span> {durationStr}
</span>
<span className="text-[10px] text-text-secondary/40">
{new Date(recording.created_at).toLocaleString()}
</span>
</div> </div>
</div> </div>
<div className="flex gap-1 shrink-0" onClick={(e) => e.stopPropagation()}> <div className="flex shrink-0 gap-1" onClick={(e) => e.stopPropagation()}>
{recording.download_url && ( {recording.download_url && (
<button <button
type="button" type="button"
onClick={handleDownload} onClick={handleDownload}
disabled={downloading} disabled={downloading}
title="Download" title="Download"
className="size-7 flex items-center justify-center rounded glass hover:glass-elevated transition-all disabled:opacity-50" className="flex size-7 items-center justify-center rounded glass hover:glass-elevated transition-all disabled:opacity-50"
> >
{downloading ? ( {downloading ? (
<Loader2 className="size-3 text-text-secondary/60 animate-spin" /> <Loader2 className="size-3 animate-spin text-text-secondary/60" />
) : ( ) : (
<Download className="size-3 text-text-secondary/60" /> <Download className="size-3 text-text-secondary/60" />
)} )}
@@ -2,56 +2,138 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GlassPanel } from "@/components/glass/panel"; import { GlassPanel } from "@/components/glass/panel";
import { X } from "lucide-react"; import { Loader2, Pause, Play, X } from "lucide-react";
interface RecordingPlayerProps { interface RecordingPlayerProps {
url?: string; url?: string;
filename?: string; filename?: string;
playing: boolean;
loading: boolean;
audioRef: React.RefObject<HTMLAudioElement | null>;
onToggle: () => void;
onStateChange: (s: { playing: boolean; loading: boolean }) => void;
onClose: () => void; onClose: () => void;
} }
export function RecordingPlayer({ url, filename, onClose }: RecordingPlayerProps) { export function RecordingPlayer({
const audioRef = useRef<HTMLAudioElement>(null); url,
filename,
playing,
loading,
audioRef,
onToggle,
onStateChange,
onClose,
}: RecordingPlayerProps) {
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Load the track whenever the URL changes; the click that opened the player
// counts as a user gesture so autoplay is allowed.
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!url || !audio) return; if (!url || !audio) return;
setError(false); setError(false);
// Fresh element state: reset src, load, then play (the click that opened setProgress(0);
// the player counts as a user gesture, so autoplay is allowed). setDuration(0);
audio.src = url; audio.src = url;
audio.load(); audio.load();
const p = audio.play(); const p = audio.play();
if (p) p.catch(() => setError(true)); if (p) p.catch(() => {});
}, [url]); }, [url, audioRef]);
// Progress ticker + cleanup.
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
const audio = audioRef.current;
if (!audio) return;
if (duration === 0 && !Number.isNaN(audio.duration)) setDuration(audio.duration);
if (!Number.isNaN(audio.currentTime)) setProgress(audio.currentTime);
}, 250);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [duration, audioRef]);
if (!url) return null; if (!url) return null;
const fmt = (s: number) => {
if (!Number.isFinite(s) || s <= 0) return "0:00";
const m = Math.floor(s / 60);
const ss = Math.floor(s % 60);
return `${m}:${String(ss).padStart(2, "0")}`;
};
const pct = duration > 0 ? Math.min(100, (progress / duration) * 100) : 0;
return ( return (
<GlassPanel dense className="fixed bottom-20 left-4 z-30 w-80 flex flex-col gap-1.5"> <GlassPanel dense className="fixed bottom-20 left-4 z-30 w-80 flex flex-col gap-1.5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<audio <button
ref={audioRef} type="button"
controls onClick={onToggle}
preload="auto" disabled={loading}
className="flex-1 h-8 [&::-webkit-media-controls-panel]:bg-transparent" title={playing ? "Pause" : "Play"}
onError={() => setError(true)} className="flex size-8 shrink-0 items-center justify-center rounded-full glass-elevated transition-transform hover:scale-105 disabled:opacity-60"
/> >
{loading ? (
<Loader2 className="size-3.5 animate-spin text-primary" />
) : playing ? (
<Pause className="size-3.5 text-primary" />
) : (
<Play className="size-3.5 text-primary ml-0.5" />
)}
</button>
<div className="min-w-0 flex-1">
<div className="truncate text-[11px] font-medium text-text-primary">
{filename ?? "recording"}
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-text-secondary/60">
{fmt(progress)} / {fmt(duration)}
</span>
{loading && (
<span className="text-[10px] text-primary/80">loading</span>
)}
{error && (
<span className="text-[10px] text-red-400/90">playback failed</span>
)}
</div>
</div>
<button type="button" onClick={onClose} className="shrink-0"> <button type="button" onClick={onClose} className="shrink-0">
<X className="size-3.5 text-text-secondary/60 hover:text-text-primary" /> <X className="size-3.5 text-text-secondary/60 hover:text-text-primary" />
</button> </button>
</div> </div>
<div className="flex items-center justify-between px-0.5">
<span className="truncate text-[10px] font-mono text-text-secondary/60"> {/* Progress bar */}
{filename ?? "recording"} <div className="h-1 w-full overflow-hidden rounded-full bg-glass-border">
</span> <div
{error && ( className="h-full rounded-full bg-primary transition-[width] duration-300"
<span className="shrink-0 text-[10px] text-red-400/90"> style={{ width: `${pct}%` }}
playback failed />
</span>
)}
</div> </div>
{/* Hidden audio element drives everything above. */}
<audio
ref={audioRef}
preload="auto"
onLoadStart={() => onStateChange({ playing: false, loading: true })}
onWaiting={() => onStateChange({ playing: false, loading: true })}
onCanPlay={() => onStateChange({ playing: true, loading: false })}
onPlaying={() => onStateChange({ playing: true, loading: false })}
onPlay={() => onStateChange({ playing: true, loading: false })}
onPause={() => onStateChange({ playing: false, loading: false })}
onEnded={() => onStateChange({ playing: false, loading: false })}
onError={() => {
setError(true);
onStateChange({ playing: false, loading: false });
}}
className="hidden"
/>
</GlassPanel> </GlassPanel>
); );
} }