Skip to content

Commit 6ca5624

Browse files
committed
feat: add audio player component and update conversation views
1 parent 7cdfc1d commit 6ca5624

File tree

3 files changed

+377
-62
lines changed

3 files changed

+377
-62
lines changed
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
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

Comments
 (0)