From 98064d1dd99c0b4c876f0cd06ed76fb5da89b11a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 1 Aug 2026 16:54:58 +0700 Subject: [PATCH] =?UTF-8?q?feat(fe):=20recordings=20=E2=80=94=20visible=20?= =?UTF-8?q?playing/loading/paused=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../src/app/(dashboard)/recordings/page.tsx | 29 +++- services/frontend/src/app/globals.css | 32 +++++ .../components/recordings/recording-card.tsx | 96 ++++++++++--- .../recordings/recording-player.tsx | 130 ++++++++++++++---- 4 files changed, 241 insertions(+), 46 deletions(-) diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index 7a4655c..b03667b 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -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(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isLoadingAudio, setIsLoadingAudio] = useState(false); const [tab, setTab] = useState("library"); const ws = useWebSocket(); + const audioRef = useRef(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() { 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() { togglePlay(playingId!)} + onStateChange={(s) => { + setIsPlaying(s.playing); + setIsLoadingAudio(s.loading); + }} onClose={() => setPlayingId(null)} /> diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css index 15bf7e0..bf9feb3 100644 --- a/services/frontend/src/app/globals.css +++ b/services/frontend/src/app/globals.css @@ -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 */ diff --git a/services/frontend/src/components/recordings/recording-card.tsx b/services/frontend/src/components/recordings/recording-card.tsx index b3e435e..212e7d0 100644 --- a/services/frontend/src/components/recordings/recording-card.tsx +++ b/services/frontend/src/components/recordings/recording-card.tsx @@ -1,16 +1,28 @@ "use client"; 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 type { VoiceRecording } from "@/lib/types"; interface RecordingCardProps { 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 durationStr = recording.duration_bytes ? `${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 ( - onPlay(recording.id)}> + onTogglePlay(recording.id)} + >
-
+
- {recording.username} - {recording.channel_name} + + {recording.username} + + + {recording.channel_name} + + {active && ( + + {loading + ? "Loading" + : playing + ? "Now Playing" + : "Paused"} + + )}
- {/* Mini waveform bar */} -
- {Array.from({ length: 40 }, (_, i) => ( + {/* Waveform — bounces while playing, pulses while loading */} +
+ {Array.from({ length: BAR_COUNT }, (_, i) => (
))}
- {durationStr} - {new Date(recording.created_at).toLocaleString()} + + {durationStr} + + + {new Date(recording.created_at).toLocaleString()} +
-
e.stopPropagation()}> +
e.stopPropagation()}> {recording.download_url && ( + +
+
+ {filename ?? "recording"} +
+
+ + {fmt(progress)} / {fmt(duration)} + + {loading && ( + loading… + )} + {error && ( + playback failed + )} +
+
+
-
- - {filename ?? "recording"} - - {error && ( - - playback failed - - )} + + {/* Progress bar */} +
+
+ + {/* Hidden audio element drives everything above. */} +