1
+ <template >
2
+ <div class =" rounded-lg bg-gradient-to-r from-gray-50 to-gray-100 p-4 shadow-sm dark:from-gray-800 dark:to-gray-900" >
3
+ <div class =" space-y-3" >
4
+ <!-- Top row: Title and metadata -->
5
+ <div class =" flex items-center justify-between" >
6
+ <div class =" flex items-center gap-3" >
7
+ <div class =" flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/50" >
8
+ <svg class =" h-5 w-5 text-blue-600 dark:text-blue-400" fill =" currentColor" viewBox =" 0 0 20 20" >
9
+ <path d =" M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
10
+ </svg >
11
+ </div >
12
+ <div >
13
+ <h3 class =" text-sm font-medium text-gray-900 dark:text-gray-100" >Conversation Recording</h3 >
14
+ <div class =" flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400" >
15
+ <span v-if =" duration" >{{ formatDuration(duration) }}</span >
16
+ <span v-if =" size" >{{ formatFileSize(size) }}</span >
17
+ </div >
18
+ </div >
19
+ </div >
20
+
21
+ <!-- Download button -->
22
+ <button
23
+ @click =" downloadAudio"
24
+ class =" rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-300"
25
+ title =" Download recording"
26
+ >
27
+ <svg class =" h-5 w-5" fill =" none" stroke =" currentColor" viewBox =" 0 0 24 24" >
28
+ <path stroke-linecap =" round" stroke-linejoin =" round" stroke-width =" 2" d =" M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
29
+ </svg >
30
+ </button >
31
+ </div >
32
+
33
+ <!-- Progress bar -->
34
+ <div class =" relative" >
35
+ <div class =" flex items-center gap-3" >
36
+ <span class =" min-w-[40px] text-xs font-medium text-gray-600 dark:text-gray-400" >
37
+ {{ formatTime(currentTime) }}
38
+ </span >
39
+ <div class =" relative flex-1" >
40
+ <div
41
+ class =" h-1.5 w-full cursor-pointer rounded-full bg-gray-300 dark:bg-gray-700"
42
+ @click =" seek"
43
+ ref =" progressBar"
44
+ >
45
+ <div
46
+ class =" h-1.5 rounded-full bg-blue-500 transition-all dark:bg-blue-400"
47
+ :style =" { width: `${progress}%` }"
48
+ ></div >
49
+ <div
50
+ class =" absolute -top-1 h-3.5 w-3.5 rounded-full bg-blue-500 shadow-md transition-all dark:bg-blue-400"
51
+ :style =" { left: `calc(${progress}% - 7px)` }"
52
+ ></div >
53
+ </div >
54
+ </div >
55
+ <span class =" min-w-[40px] text-right text-xs font-medium text-gray-600 dark:text-gray-400" >
56
+ {{ formatTime(totalDuration) }}
57
+ </span >
58
+ </div >
59
+ </div >
60
+
61
+ <!-- Controls -->
62
+ <div class =" flex items-center justify-between" >
63
+ <div class =" flex items-center gap-2" >
64
+ <!-- Play/Pause button -->
65
+ <button
66
+ @click =" togglePlayPause"
67
+ class =" flex h-12 w-12 items-center justify-center rounded-full bg-blue-500 text-white shadow-lg transition-all hover:bg-blue-600 hover:shadow-xl active:scale-95 dark:bg-blue-600 dark:hover:bg-blue-700"
68
+ >
69
+ <svg v-if =" !isPlaying" class =" h-6 w-6" fill =" currentColor" viewBox =" 0 0 20 20" >
70
+ <path d =" M6.3 2.841A1.5 1.5 0 004 4.11v11.78a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
71
+ </svg >
72
+ <svg v-else class =" h-6 w-6" fill =" currentColor" viewBox =" 0 0 20 20" >
73
+ <path d =" M5.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75A.75.75 0 007.25 3h-1.5zM12.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75a.75.75 0 00-.75-.75h-1.5z" />
74
+ </svg >
75
+ </button >
76
+
77
+ <!-- Skip backward -->
78
+ <button
79
+ @click =" skip(-10)"
80
+ class =" flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition-all hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700"
81
+ title =" Rewind 10 seconds"
82
+ >
83
+ <svg class =" h-5 w-5" fill =" currentColor" viewBox =" 0 0 20 20" >
84
+ <path d =" M8.445 14.832A1 1 0 0010 14v-2.798l5.445 3.63A1 1 0 0017 14V6a1 1 0 00-1.555-.832L10 8.798V6a1 1 0 00-1.555-.832l-6 4a1 1 0 000 1.664l6 4z" />
85
+ </svg >
86
+ </button >
87
+
88
+ <!-- Skip forward -->
89
+ <button
90
+ @click =" skip(10)"
91
+ class =" flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition-all hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700"
92
+ title =" Forward 10 seconds"
93
+ >
94
+ <svg class =" h-5 w-5" fill =" currentColor" viewBox =" 0 0 20 20" >
95
+ <path d =" M4.555 5.168A1 1 0 003 6v8a1 1 0 001.555.832L10 11.202V14a1 1 0 001.555.832l6-4a1 1 0 000-1.664l-6-4A1 1 0 0010 6v2.798l-5.445-3.63z" />
96
+ </svg >
97
+ </button >
98
+ </div >
99
+
100
+ <!-- Volume and Speed controls -->
101
+ <div class =" flex items-center gap-4" >
102
+ <!-- Speed control -->
103
+ <div class =" relative" >
104
+ <button
105
+ @click =" showSpeedMenu = !showSpeedMenu"
106
+ class =" flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700"
107
+ >
108
+ {{ playbackRate }}x
109
+ </button >
110
+
111
+ <!-- Speed menu -->
112
+ <div
113
+ v-if =" showSpeedMenu"
114
+ class =" absolute bottom-full right-0 mb-2 rounded-lg bg-white py-1 shadow-lg dark:bg-gray-800"
115
+ >
116
+ <button
117
+ v-for =" speed in [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]"
118
+ :key =" speed"
119
+ @click =" setPlaybackRate(speed)"
120
+ :class =" [
121
+ 'block w-full px-4 py-1.5 text-left text-sm transition-colors',
122
+ playbackRate === speed
123
+ ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
124
+ : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
125
+ ]"
126
+ >
127
+ {{ speed }}x
128
+ </button >
129
+ </div >
130
+ </div >
131
+
132
+ <!-- Volume control -->
133
+ <div class =" flex items-center gap-2" >
134
+ <button
135
+ @click =" toggleMute"
136
+ class =" text-gray-600 transition-colors hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
137
+ >
138
+ <svg v-if =" isMuted || volume === 0" class =" h-5 w-5" fill =" currentColor" viewBox =" 0 0 20 20" >
139
+ <path fill-rule =" evenodd" d =" M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule =" evenodd" />
140
+ </svg >
141
+ <svg v-else-if =" volume < 0.5" class =" h-5 w-5" fill =" currentColor" viewBox =" 0 0 20 20" >
142
+ <path fill-rule =" evenodd" d =" M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414z" clip-rule =" evenodd" />
143
+ </svg >
144
+ <svg v-else class =" h-5 w-5" fill =" currentColor" viewBox =" 0 0 20 20" >
145
+ <path fill-rule =" evenodd" d =" M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414z" clip-rule =" evenodd" />
146
+ <path d =" M11.829 4.515a1 1 0 011.414 0 6 6 0 010 8.485 1 1 0 01-1.414-1.414 4 4 0 000-5.657 1 1 0 010-1.414z" />
147
+ </svg >
148
+ </button >
149
+ <input
150
+ type =" range"
151
+ min =" 0"
152
+ max =" 1"
153
+ step =" 0.1"
154
+ v-model =" volume"
155
+ @input =" setVolume"
156
+ class =" h-1 w-20 cursor-pointer appearance-none rounded-lg bg-gray-300 dark:bg-gray-700"
157
+ >
158
+ </div >
159
+ </div >
160
+ </div >
161
+ </div >
162
+
163
+ <!-- Hidden audio element -->
164
+ <audio
165
+ ref =" audioElement"
166
+ :src =" src"
167
+ @loadedmetadata =" onLoadedMetadata"
168
+ @timeupdate =" onTimeUpdate"
169
+ @ended =" onEnded"
170
+ preload =" metadata"
171
+ ></audio >
172
+ </div >
173
+ </template >
174
+
175
+ <script setup lang="ts">
176
+ import { ref , onMounted , onUnmounted , watch , computed } from ' vue' ;
177
+
178
+ interface Props {
179
+ src: string ;
180
+ duration? : number ;
181
+ size? : number ;
182
+ }
183
+
184
+ const props = defineProps <Props >();
185
+
186
+ // State
187
+ const audioElement = ref <HTMLAudioElement | null >(null );
188
+ const progressBar = ref <HTMLDivElement | null >(null );
189
+ const isPlaying = ref (false );
190
+ const currentTime = ref (0 );
191
+ const totalDuration = ref (0 );
192
+ const volume = ref (1 );
193
+ const isMuted = ref (false );
194
+ const playbackRate = ref (1 );
195
+ const showSpeedMenu = ref (false );
196
+
197
+ // Computed
198
+ const progress = computed (() => {
199
+ if (! totalDuration .value ) return 0 ;
200
+ return (currentTime .value / totalDuration .value ) * 100 ;
201
+ });
202
+
203
+ // Methods
204
+ const formatTime = (seconds : number ): string => {
205
+ if (! seconds || isNaN (seconds )) return ' 0:00' ;
206
+ const mins = Math .floor (seconds / 60 );
207
+ const secs = Math .floor (seconds % 60 );
208
+ return ` ${mins }:${secs .toString ().padStart (2 , ' 0' )} ` ;
209
+ };
210
+
211
+ const formatDuration = (seconds : number ): string => {
212
+ if (! seconds ) return ' ' ;
213
+ const mins = Math .floor (seconds / 60 );
214
+ const secs = seconds % 60 ;
215
+ return secs > 0 ? ` ${mins }m ${secs }s ` : ` ${mins }m ` ;
216
+ };
217
+
218
+ const formatFileSize = (bytes : number ): string => {
219
+ if (! bytes ) return ' ' ;
220
+ const mb = bytes / (1024 * 1024 );
221
+ return ` ${mb .toFixed (1 )} MB ` ;
222
+ };
223
+
224
+ const togglePlayPause = () => {
225
+ if (! audioElement .value ) return ;
226
+
227
+ if (isPlaying .value ) {
228
+ audioElement .value .pause ();
229
+ } else {
230
+ audioElement .value .play ();
231
+ }
232
+ isPlaying .value = ! isPlaying .value ;
233
+ };
234
+
235
+ const seek = (event : MouseEvent ) => {
236
+ if (! audioElement .value || ! progressBar .value ) return ;
237
+
238
+ const rect = progressBar .value .getBoundingClientRect ();
239
+ const percent = (event .clientX - rect .left ) / rect .width ;
240
+ const newTime = percent * totalDuration .value ;
241
+
242
+ audioElement .value .currentTime = newTime ;
243
+ currentTime .value = newTime ;
244
+ };
245
+
246
+ const skip = (seconds : number ) => {
247
+ if (! audioElement .value ) return ;
248
+
249
+ const newTime = Math .max (0 , Math .min (totalDuration .value , audioElement .value .currentTime + seconds ));
250
+ audioElement .value .currentTime = newTime ;
251
+ currentTime .value = newTime ;
252
+ };
253
+
254
+ const setPlaybackRate = (rate : number ) => {
255
+ if (! audioElement .value ) return ;
256
+
257
+ playbackRate .value = rate ;
258
+ audioElement .value .playbackRate = rate ;
259
+ showSpeedMenu .value = false ;
260
+ };
261
+
262
+ const toggleMute = () => {
263
+ if (! audioElement .value ) return ;
264
+
265
+ isMuted .value = ! isMuted .value ;
266
+ audioElement .value .muted = isMuted .value ;
267
+ };
268
+
269
+ const setVolume = () => {
270
+ if (! audioElement .value ) return ;
271
+
272
+ audioElement .value .volume = volume .value ;
273
+ if (volume .value > 0 && isMuted .value ) {
274
+ isMuted .value = false ;
275
+ audioElement .value .muted = false ;
276
+ }
277
+ };
278
+
279
+ const downloadAudio = () => {
280
+ const a = document .createElement (' a' );
281
+ a .href = props .src ;
282
+ a .download = ` recording_${new Date ().toISOString ()}.wav ` ;
283
+ document .body .appendChild (a );
284
+ a .click ();
285
+ document .body .removeChild (a );
286
+ };
287
+
288
+ // Event handlers
289
+ const onLoadedMetadata = () => {
290
+ if (! audioElement .value ) return ;
291
+ totalDuration .value = audioElement .value .duration ;
292
+ };
293
+
294
+ const onTimeUpdate = () => {
295
+ if (! audioElement .value ) return ;
296
+ currentTime .value = audioElement .value .currentTime ;
297
+ };
298
+
299
+ const onEnded = () => {
300
+ isPlaying .value = false ;
301
+ currentTime .value = 0 ;
302
+ };
303
+
304
+ // Close speed menu when clicking outside
305
+ const handleClickOutside = (event : MouseEvent ) => {
306
+ const target = event .target as HTMLElement ;
307
+ if (! target .closest (' .relative' )) {
308
+ showSpeedMenu .value = false ;
309
+ }
310
+ };
311
+
312
+ // Lifecycle
313
+ onMounted (() => {
314
+ document .addEventListener (' click' , handleClickOutside );
315
+ });
316
+
317
+ onUnmounted (() => {
318
+ document .removeEventListener (' click' , handleClickOutside );
319
+ if (audioElement .value ) {
320
+ audioElement .value .pause ();
321
+ }
322
+ });
323
+
324
+ // Watch for src changes
325
+ watch (() => props .src , () => {
326
+ if (audioElement .value ) {
327
+ audioElement .value .load ();
328
+ isPlaying .value = false ;
329
+ currentTime .value = 0 ;
330
+ }
331
+ });
332
+ </script >
333
+
334
+ <style scoped>
335
+ /* Custom range input styles */
336
+ input [type = " range" ] {
337
+ -webkit-appearance : none ;
338
+ }
339
+
340
+ input [type = " range" ]::-webkit-slider-thumb {
341
+ -webkit-appearance : none ;
342
+ width : 12px ;
343
+ height : 12px ;
344
+ background : #3b82f6 ;
345
+ border-radius : 50% ;
346
+ cursor : pointer ;
347
+ }
348
+
349
+ input [type = " range" ]::-moz-range-thumb {
350
+ width : 12px ;
351
+ height : 12px ;
352
+ background : #3b82f6 ;
353
+ border-radius : 50% ;
354
+ cursor : pointer ;
355
+ border : none ;
356
+ }
357
+
358
+ /* Dark mode adjustments */
359
+ .dark input [type = " range" ]::-webkit-slider-thumb {
360
+ background : #60a5fa ;
361
+ }
362
+
363
+ .dark input [type = " range" ]::-moz-range-thumb {
364
+ background : #60a5fa ;
365
+ }
366
+ </style >
0 commit comments