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