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";
|
"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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user