File size: 5,079 Bytes
8fc86e0 b2d4d0e 8fc86e0 b2d4d0e 8fc86e0 b2d4d0e 65949d0 b2d4d0e 8fc86e0 65949d0 8fc86e0 65949d0 8fc86e0 65949d0 8fc86e0 65949d0 8fc86e0 b2d4d0e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | import { useEffect, useRef, useState } from "react";
import { deleteVoice, listVoices, setFavorite, type VoiceRecord } from "@/lib/idb";
import { cn } from "@/lib/utils";
type Props = {
selectedId?: number;
onSelect: (v: VoiceRecord) => void;
refreshKey?: number;
};
export default function VoiceLibrary({ selectedId, onSelect, refreshKey }: Props) {
const [voices, setVoices] = useState<VoiceRecord[]>([]);
const [playingId, setPlayingId] = useState<number | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const urlRef = useRef<string | null>(null);
useEffect(() => {
listVoices().then(setVoices);
}, [refreshKey]);
useEffect(() => {
return () => {
audioRef.current?.pause();
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
};
}, []);
function stop() {
audioRef.current?.pause();
audioRef.current = null;
if (urlRef.current) {
URL.revokeObjectURL(urlRef.current);
urlRef.current = null;
}
setPlayingId(null);
}
function play(v: VoiceRecord) {
stop();
const url = URL.createObjectURL(v.blob);
const audio = new Audio(url);
audio.onended = () => stop();
audio.onerror = () => stop();
audioRef.current = audio;
urlRef.current = url;
setPlayingId(v.id ?? null);
audio.play().catch(() => stop());
}
function toggle(v: VoiceRecord) {
if (playingId === v.id) stop();
else play(v);
}
if (voices.length === 0) {
return (
<p className="text-sm text-muted-foreground italic">
Voices will appear here once you upload or record one.
</p>
);
}
return (
<ul className="space-y-2">
{voices.map((v, i) => {
const isPlaying = playingId === v.id;
return (
<li
key={v.id}
className={cn(
"card-paper p-3 transition-colors",
selectedId === v.id
? "border-[hsl(var(--ember))]/60 bg-[hsl(var(--ember))]/5"
: "hover:border-foreground/30",
)}
>
<div className="flex items-start gap-3">
<span className="marker-num pt-0.5">
{String(i + 1).padStart(2, "0")}
</span>
<button
type="button"
className="flex-1 text-left"
onClick={() => onSelect(v)}
>
<div className="display-serif text-[18px] leading-tight">
{v.name}
</div>
<div className="label-mono mt-1">
{(v.durationMs / 1000).toFixed(1)}s · {v.sampleRate} Hz
</div>
</button>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={isPlaying ? "Stop" : "Play"}
onClick={() => toggle(v)}
className={cn(
"size-7 grid place-items-center rounded-sm border transition-colors",
isPlaying
? "border-[hsl(var(--ember))]/60 text-[hsl(var(--ember))] bg-[hsl(var(--ember))]/10"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}
>
{isPlaying ? (
<span className="block size-2 bg-current rounded-[1px]" />
) : (
<span
className="block size-0 ml-[2px]"
style={{
borderLeft: "7px solid currentColor",
borderTop: "5px solid transparent",
borderBottom: "5px solid transparent",
}}
/>
)}
</button>
<button
type="button"
aria-label={v.isFavorite ? "Unfavorite" : "Favorite"}
onClick={() =>
setFavorite(v.id!, !v.isFavorite).then(() =>
listVoices().then(setVoices),
)
}
className={cn(
"text-base leading-none transition-colors",
v.isFavorite
? "text-[hsl(var(--ember))]"
: "text-muted-foreground hover:text-foreground",
)}
>
{v.isFavorite ? "★" : "☆"}
</button>
<button
type="button"
aria-label="Delete"
onClick={() => {
if (playingId === v.id) stop();
deleteVoice(v.id!).then(() => listVoices().then(setVoices));
}}
className="text-xs text-muted-foreground hover:text-red-400 transition-colors"
>
✕
</button>
</div>
</div>
</li>
);
})}
</ul>
);
}
|