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";
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 { SubNav } from "@/components/layout/sub-nav";
import { RecordingCard } from "@/components/recordings/recording-card";
@@ -22,8 +22,11 @@ export default function RecordingsPage() {
mutate: refetch,
} = useRecordings();
const [playingId, setPlayingId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
const [tab, setTab] = useState<RecordingsTab>("library");
const ws = useWebSocket();
const audioRef = useRef<HTMLAudioElement | null>(null);
// Live-update the library when the gateway publishes voice_recording_uploaded
useRecordingsWsSync(ws);
@@ -33,6 +36,17 @@ export default function RecordingsPage() {
? recordings.find((r: VoiceRecording) => r.id === playingId)
: 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 list = recordings ?? [];
const totalSize = list.reduce((sum, r) => sum + (r.size_bytes ?? 0), 0);
@@ -80,7 +94,10 @@ export default function RecordingsPage() {
<RecordingCard
key={rec.id}
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 && (
@@ -154,6 +171,14 @@ export default function RecordingsPage() {
<RecordingPlayer
url={currentTrack?.download_url ?? 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)}
/>
</div>
+32
View File
@@ -167,6 +167,38 @@
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 ─────────────────────── */
/* Gradient text */