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:
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onPlay(recording.id); }}
|
||||
className="size-10 flex items-center justify-center rounded-full glass-elevated shrink-0 hover:scale-105 transition-transform"
|
||||
onClick={(e) => {
|
||||
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>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-text-primary">{recording.username}</span>
|
||||
<span className="text-[10px] text-text-secondary/40 font-mono">{recording.channel_name}</span>
|
||||
<span className="font-semibold text-text-primary">
|
||||
{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>
|
||||
|
||||
{/* Mini waveform bar */}
|
||||
<div className="flex items-end gap-0.5 h-8 my-2">
|
||||
{Array.from({ length: 40 }, (_, i) => (
|
||||
{/* Waveform — bounces while playing, pulses while loading */}
|
||||
<div className="my-2 flex h-8 items-end gap-0.5 overflow-hidden">
|
||||
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t-sm bg-primary/60"
|
||||
style={{ height: `${20 + Math.sin(i * 0.5) * 15 + Math.random() * 10}%` }}
|
||||
className={`flex-1 rounded-t-sm transition-colors ${
|
||||
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 className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-mono text-text-secondary/60">{durationStr}</span>
|
||||
<span className="text-[10px] text-text-secondary/40">{new Date(recording.created_at).toLocaleString()}</span>
|
||||
<span className="text-[10px] font-mono text-text-secondary/60">
|
||||
{durationStr}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-secondary/40">
|
||||
{new Date(recording.created_at).toLocaleString()}
|
||||
</span>
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
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 ? (
|
||||
<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" />
|
||||
)}
|
||||
|
||||
@@ -2,56 +2,138 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { GlassPanel } from "@/components/glass/panel";
|
||||
import { X } from "lucide-react";
|
||||
import { Loader2, Pause, Play, X } from "lucide-react";
|
||||
|
||||
interface RecordingPlayerProps {
|
||||
url?: string;
|
||||
filename?: string;
|
||||
playing: boolean;
|
||||
loading: boolean;
|
||||
audioRef: React.RefObject<HTMLAudioElement | null>;
|
||||
onToggle: () => void;
|
||||
onStateChange: (s: { playing: boolean; loading: boolean }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RecordingPlayer({ url, filename, onClose }: RecordingPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
export function RecordingPlayer({
|
||||
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 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(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!url || !audio) return;
|
||||
setError(false);
|
||||
// Fresh element state: reset src, load, then play (the click that opened
|
||||
// the player counts as a user gesture, so autoplay is allowed).
|
||||
setProgress(0);
|
||||
setDuration(0);
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
const p = audio.play();
|
||||
if (p) p.catch(() => setError(true));
|
||||
}, [url]);
|
||||
if (p) p.catch(() => {});
|
||||
}, [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;
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
controls
|
||||
preload="auto"
|
||||
className="flex-1 h-8 [&::-webkit-media-controls-panel]:bg-transparent"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={loading}
|
||||
title={playing ? "Pause" : "Play"}
|
||||
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">
|
||||
<X className="size-3.5 text-text-secondary/60 hover:text-text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<span className="truncate text-[10px] font-mono text-text-secondary/60">
|
||||
{filename ?? "recording"}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="shrink-0 text-[10px] text-red-400/90">
|
||||
playback failed
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-glass-border">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user