|
@@ -0,0 +1,219 @@
|
|
|
+<template>
|
|
|
+ <div class="voice-message" @click="togglePlay">
|
|
|
+ <!-- 波形动画 -->
|
|
|
+ <div class="wave">
|
|
|
+ <span
|
|
|
+ v-for="i in 5"
|
|
|
+ :key="i"
|
|
|
+ class="bar"
|
|
|
+ :style="{
|
|
|
+ height: isPlaying ? `${randomHeights[i-1]}px` : `4px`
|
|
|
+ }"
|
|
|
+ ></span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 时长显示 -->
|
|
|
+ <span class="duration">{{ computedDuration }}″</span>
|
|
|
+
|
|
|
+ <!-- 隐藏的audio元素 -->
|
|
|
+ <audio
|
|
|
+ ref="audioEl"
|
|
|
+ :src="src"
|
|
|
+ preload="metadata"
|
|
|
+ @loadedmetadata="handleMetadata"
|
|
|
+ @ended="handleEnded"
|
|
|
+ @error="handleError"
|
|
|
+ ></audio>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ src: String,
|
|
|
+ isSender: Boolean
|
|
|
+})
|
|
|
+
|
|
|
+const audioEl = ref(null)
|
|
|
+const isPlaying = ref(false)
|
|
|
+const rawDuration = ref(0)
|
|
|
+const randomHeights = ref([6, 9, 12])
|
|
|
+const animationFrame = ref(null)
|
|
|
+
|
|
|
+// 计算后的时长(处理Infinity情况)
|
|
|
+const computedDuration = computed(() => {
|
|
|
+ return isFinite(rawDuration.value) ? Math.round(rawDuration.value) : '--'
|
|
|
+})
|
|
|
+
|
|
|
+// 初始化加载
|
|
|
+onMounted(() => {
|
|
|
+ loadAudio()
|
|
|
+})
|
|
|
+
|
|
|
+// 监听URL变化
|
|
|
+watch(() => props.src, () =>{
|
|
|
+ loadAudio()
|
|
|
+})
|
|
|
+
|
|
|
+// 加载音频
|
|
|
+const loadAudio = async () => {
|
|
|
+ if (!props.src) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 重置audio元素
|
|
|
+ if (audioEl.value) {
|
|
|
+ audioEl.value.pause()
|
|
|
+ audioEl.value.currentTime = 0
|
|
|
+ audioEl.value.src = props.src
|
|
|
+ await audioEl.value.load()
|
|
|
+ }
|
|
|
+
|
|
|
+ // 备用方案:如果loadedmetadata未触发
|
|
|
+ setTimeout(() => {
|
|
|
+ if (rawDuration.value === 0) {
|
|
|
+ getDurationFallback()
|
|
|
+ }
|
|
|
+ }, 500)
|
|
|
+ } catch (err) {
|
|
|
+ console.error('音频加载失败:', err)
|
|
|
+ getDurationFallback()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 正常获取时长
|
|
|
+const handleMetadata = () => {
|
|
|
+ if (audioEl.value && isFinite(audioEl.value.duration)) {
|
|
|
+ rawDuration.value = audioEl.value.duration
|
|
|
+ } else {
|
|
|
+ getDurationFallback()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 备用方案:使用Web Audio API
|
|
|
+const getDurationFallback = async () => {
|
|
|
+ try {
|
|
|
+ const dur = await getAudioDuration(props.src)
|
|
|
+ if (dur > 0) rawDuration.value = dur
|
|
|
+ } catch (err) {
|
|
|
+ console.error('备用方案获取时长失败:', err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Web Audio API实现
|
|
|
+const getAudioDuration = (url) => {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ if (!url) return resolve(0)
|
|
|
+
|
|
|
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
|
|
+ const request = new XMLHttpRequest()
|
|
|
+
|
|
|
+ request.open('GET', url, true)
|
|
|
+ request.responseType = 'arraybuffer'
|
|
|
+
|
|
|
+ request.onload = () => {
|
|
|
+ audioContext.decodeAudioData(
|
|
|
+ request.response,
|
|
|
+ (buffer) => resolve(buffer.duration),
|
|
|
+ () => resolve(0)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ request.onerror = () => resolve(0)
|
|
|
+ request.send()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 播放控制
|
|
|
+const togglePlay = () => {
|
|
|
+ if (!audioEl.value) return
|
|
|
+
|
|
|
+ if (isPlaying.value) {
|
|
|
+ audioEl.value.pause()
|
|
|
+ } else {
|
|
|
+ audioEl.value.play()
|
|
|
+ }
|
|
|
+ isPlaying.value = !isPlaying.value
|
|
|
+ updateWaveAnimation()
|
|
|
+}
|
|
|
+
|
|
|
+// 波形动画
|
|
|
+const updateWaveAnimation = () => {
|
|
|
+ if (isPlaying.value) {
|
|
|
+ cancelAnimationFrame(animationFrame.value)
|
|
|
+ const animate = () => {
|
|
|
+ randomHeights.value = [
|
|
|
+ Math.random() * 9 + 6,
|
|
|
+ Math.random() * 12 + 6,
|
|
|
+ Math.random() * 9 + 6,
|
|
|
+ Math.random() * 12 + 6,
|
|
|
+ Math.random() * 9 + 6,
|
|
|
+ ]
|
|
|
+ animationFrame.value = requestAnimationFrame(animate)
|
|
|
+ }
|
|
|
+ animate()
|
|
|
+ } else {
|
|
|
+ cancelAnimationFrame(animationFrame.value)
|
|
|
+ randomHeights.value = [6, 9, 12]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 音频结束处理
|
|
|
+const handleEnded = () => {
|
|
|
+ isPlaying.value = false
|
|
|
+ updateWaveAnimation()
|
|
|
+}
|
|
|
+
|
|
|
+// 错误处理
|
|
|
+const handleError = () => {
|
|
|
+ console.error('音频播放错误')
|
|
|
+ isPlaying.value = false
|
|
|
+ updateWaveAnimation()
|
|
|
+}
|
|
|
+
|
|
|
+// 清理资源
|
|
|
+onUnmounted(() => {
|
|
|
+ cancelAnimationFrame(animationFrame.value)
|
|
|
+ if (audioEl.value) {
|
|
|
+ audioEl.value.pause()
|
|
|
+ audioEl.value.src = ''
|
|
|
+ audioEl.value.remove()
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="less" scoped>
|
|
|
+.voice-message {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 8px 12px;
|
|
|
+ border-radius: 5px;
|
|
|
+ background-color: v-bind("isSender ? '#E5F7FF' : '#F5F5F5'");
|
|
|
+ cursor: pointer;
|
|
|
+ user-select: none;
|
|
|
+ max-width: 200px;
|
|
|
+}
|
|
|
+
|
|
|
+.wave {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+ height: 100%;
|
|
|
+ margin: 0 8px;
|
|
|
+ gap: 3px;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.bar {
|
|
|
+ display: flex;
|
|
|
+ width: 2.5px;
|
|
|
+ background-color: v-bind("isSender ? '#07C160' : '#2B87EB'");
|
|
|
+ border-radius: 2px;
|
|
|
+ transition: height 0.15s ease-out;
|
|
|
+}
|
|
|
+
|
|
|
+.duration {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #666;
|
|
|
+ min-width: 20px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+</style>
|