chore(services): update components based on recent changes

Changes:
 services/frontend/src/features/live/components/RecordingsSubPanel.tsx |  45 ++------
 services/frontend/src/features/live/components/WaveformPlayer.tsx     | 253 +++++++++++++++++++++++++++++++++++++++++++++
 services/frontend/src/features/live/components/index.ts               |   1 +
 3 files changed, 261 insertions(+), 38 deletions(-)
This commit is contained in:
MythEclipse
2026-06-13 16:56:17 +07:00
parent 5c8fa3d254
commit 1d06de5ce5
3 changed files with 261 additions and 38 deletions
@@ -1,12 +1,13 @@
// ─── Recordings Sub-Panel ── // ─── Recordings Sub-Panel ──
import { Download, Mic, Pause, Play, Trash2 } from "lucide-react"; import { Download, Mic, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { VoiceRecording } from "../../../shared/api/client"; import type { VoiceRecording } from "../../../shared/api/client";
import { deleteRecording, listRecordings } from "../../../shared/api/client"; import { deleteRecording, listRecordings } from "../../../shared/api/client";
import { formatBytes, formatDate } from "../../../shared/lib/utils"; import { formatBytes, formatDate } from "../../../shared/lib/utils";
import { Badge, Button, Skeleton } from "../../../shared/ui"; import { Badge, Button, Skeleton } from "../../../shared/ui";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
import { WaveformPlayer } from "./WaveformPlayer";
export function RecordingsSubPanel() { export function RecordingsSubPanel() {
const [recordings, setRecordings] = useState<VoiceRecording[]>([]); const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
@@ -16,8 +17,6 @@ export function RecordingsSubPanel() {
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set()); const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const [activePlayingId, setActivePlayingId] = useState<string | null>(null);
const audioRefs = useRef<Map<string, HTMLAudioElement>>(new Map());
const loadRecordings = useCallback(async ( const loadRecordings = useCallback(async (
opts?: { signal?: AbortSignal }, opts?: { signal?: AbortSignal },
@@ -82,22 +81,6 @@ export function RecordingsSubPanel() {
} }
}, []); }, []);
const handleTogglePlay = useCallback((id: string) => {
setActivePlayingId((prev) => {
if (prev === id) {
// Pause current
audioRefs.current.get(id)?.pause();
return null;
}
// Pause any previously playing
if (prev) audioRefs.current.get(prev)?.pause();
// Play new
const audio = audioRefs.current.get(id);
if (audio) audio.play().catch(() => {});
return id;
});
}, []);
if (loading) { if (loading) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -188,25 +171,6 @@ export function RecordingsSubPanel() {
</Badge> </Badge>
{rec.download_url && ( {rec.download_url && (
<> <>
<Button
size="sm"
variant={activePlayingId === rec.id ? "default" : "outline"}
onClick={() => handleTogglePlay(rec.id)}
>
{activePlayingId === rec.id
? <Pause className="h-4 w-4" />
: <Play className="h-4 w-4" />}
</Button>
<audio
ref={(el) => {
if (el) audioRefs.current.set(rec.id, el);
else audioRefs.current.delete(rec.id);
}}
src={rec.download_url}
preload="none"
onEnded={() => setActivePlayingId((p) => p === rec.id ? null : p)}
className="hidden"
/>
<a <a
href={rec.download_url} href={rec.download_url}
download={rec.filename} download={rec.filename}
@@ -218,6 +182,11 @@ export function RecordingsSubPanel() {
)} )}
</div> </div>
</div> </div>
{rec.download_url && (
<div className="-mt-2 px-4 pb-4">
<WaveformPlayer downloadUrl={rec.download_url} filename={rec.filename} />
</div>
)}
))} ))}
{hasMore && ( {hasMore && (
<div className="flex justify-center pt-2"> <div className="flex justify-center pt-2">
@@ -0,0 +1,253 @@
// ─── Waveform Player — audio visualizer with seekable waveform bars ──────────
import { Pause, Play } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createLogger } from "../../../shared/lib/logger";
const logger = createLogger("waveform-player");
const BAR_COUNT = 64;
const SAMPLE_RATE = 24000;
interface WaveformPlayerProps {
downloadUrl: string;
filename: string;
}
export function WaveformPlayer({ downloadUrl, filename }: WaveformPlayerProps) {
const [playing, setPlaying] = useState(false);
const [peaks, setPeaks] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
const startTimeRef = useRef(0);
const startOffsetRef = useRef(0);
const rafRef = useRef<number>(0);
const decodedRef = useRef<AudioBuffer | null>(null);
const durationRef = useRef(0);
// Decode audio on mount
useEffect(() => {
let cancelled = false;
const ctx = new AudioContext();
audioContextRef.current = ctx;
fetch(downloadUrl)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then((buf) => ctx.decodeAudioData(buf))
.then((audioBuffer) => {
if (cancelled) return;
decodedRef.current = audioBuffer;
durationRef.current = audioBuffer.duration;
// Compute waveform peaks
const channel = audioBuffer.getChannelData(0);
const samplesPerBar = Math.floor(channel.length / BAR_COUNT);
const peakValues: number[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
let max = 0;
const start = i * samplesPerBar;
const end = Math.min(start + samplesPerBar, channel.length);
for (let j = start; j < end; j++) {
const abs = Math.abs(channel[j]);
if (abs > max) max = abs;
}
// Clamp so silent sections still show a tiny bar
peakValues.push(Math.max(0.01, max));
}
setPeaks(peakValues);
setLoading(false);
})
.catch((err) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : String(err);
logger.error("Failed to decode audio", { error: msg });
setError(msg);
setLoading(false);
});
return () => {
cancelled = true;
ctx.close();
};
}, [downloadUrl]);
// Draw waveform on canvas whenever peaks change or while playing
const drawWaveform = useCallback(
(progress = 0) => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 64 * dpr;
canvas.style.height = "64px";
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, 64);
if (peaks.length === 0) return;
const barWidth = rect.width / peaks.length;
const barGap = Math.max(1, barWidth * 0.15);
const barActualWidth = barWidth - barGap;
const progressPixel = rect.width * progress;
for (let i = 0; i < peaks.length; i++) {
const x = i * barWidth;
const height = Math.max(2, peaks[i] * 50);
const y = 32 - height / 2;
// Color: played vs unplayed
const isPlayed = x + barWidth <= progressPixel;
ctx.fillStyle = isPlayed ? "#23a1eb" : "#334155";
ctx.fillRect(x, y, barActualWidth, height);
}
},
[peaks],
);
// Initial draw when peaks change
useEffect(() => {
drawWaveform();
}, [drawWaveform]);
// Animation loop while playing
useEffect(() => {
if (!playing || !decodedRef.current) return;
const tick = () => {
const elapsed =
audioContextRef.current!.currentTime - startTimeRef.current;
const progress = (elapsed + startOffsetRef.current) / durationRef.current;
drawWaveform(Math.min(1, Math.max(0, progress)));
if (progress >= 1) {
setPlaying(false);
return;
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [playing, drawWaveform]);
const handleTogglePlay = useCallback(() => {
const ctx = audioContextRef.current;
const buffer = decodedRef.current;
if (!ctx || !buffer) return;
if (playing) {
// Pause
if (sourceRef.current) {
startOffsetRef.current += ctx.currentTime - startTimeRef.current;
sourceRef.current.stop();
sourceRef.current.disconnect();
sourceRef.current = null;
}
setPlaying(false);
return;
}
// Resume / start
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0, startOffsetRef.current);
startTimeRef.current = ctx.currentTime;
sourceRef.current = source;
setPlaying(true);
source.onended = () => {
if (sourceRef.current === source) {
setPlaying(false);
sourceRef.current = null;
}
};
}, [playing]);
const handleSeek = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!decodedRef.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const progress = Math.max(0, Math.min(1, x / rect.width));
const offset = progress * durationRef.current;
const ctx = audioContextRef.current;
if (ctx && sourceRef.current) {
sourceRef.current.stop();
sourceRef.current.disconnect();
}
startOffsetRef.current = offset;
startTimeRef.current = ctx?.currentTime ?? 0;
drawWaveform(progress);
if (playing && ctx) {
const buffer = decodedRef.current;
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0, offset);
startTimeRef.current = ctx.currentTime;
sourceRef.current = source;
source.onended = () => {
if (sourceRef.current === source) {
setPlaying(false);
sourceRef.current = null;
}
};
}
},
[playing, drawWaveform],
);
if (loading) {
return (
<div className="h-16 w-full animate-pulse rounded-md bg-muted" />
);
}
if (error) {
return (
<div className="h-16 w-full rounded-md bg-destructive/10 flex items-center justify-center text-xs text-destructive">
{error}
</div>
);
}
if (peaks.length === 0) return null;
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleTogglePlay}
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90"
>
{playing ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
</button>
<div
ref={containerRef}
className="relative flex-1 cursor-pointer"
onClick={handleSeek}
role="slider"
aria-label={`Playback seek for ${filename}`}
tabIndex={0}
>
<canvas ref={canvasRef} className="w-full" />
</div>
</div>
);
}
@@ -8,3 +8,4 @@ export { RecordingsSubPanel } from "./RecordingsSubPanel";
export { ScreenSubPanel } from "./ScreenSubPanel"; export { ScreenSubPanel } from "./ScreenSubPanel";
export { MicLevelMeter } from "./MicLevelMeter"; export { MicLevelMeter } from "./MicLevelMeter";
export { VoiceConnectionCard } from "./VoiceConnectionCard"; export { VoiceConnectionCard } from "./VoiceConnectionCard";
export { WaveformPlayer } from "./WaveformPlayer";