techfreakworm commited on
Commit
8fc86e0
·
unverified ·
1 Parent(s): ded73f2

feat(web): play/stop button on each voice card; one plays at a time

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