以文件方式上传音频

This commit is contained in:
bob
2025-03-23 12:30:30 +08:00
parent 4d57c5200d
commit a332753328
10 changed files with 205 additions and 190 deletions

View File

@@ -26,6 +26,7 @@
"protobufjs": "^7.4.0",
"uuid": "^10.0.0",
"vue": "^3.4.29",
"vue-audio-visual": "^3.0.10",
"vue-cropper": "^1.1.1",
"vue-router": "^4.3.3"
},

View File

@@ -7,3 +7,7 @@ export const mtsUploadService = (obj) => {
export const mtsImageService = (obj) => {
return request.get('/mts/image', { params: obj })
}
export const mtsAudioService = (obj) => {
return request.get('/mts/audio', { params: obj })
}

1
src/assets/svg/pause.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742561362047" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7934" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M682.666667 213.333333a42.666667 42.666667 0 0 1 42.666666 42.666667v512a42.666667 42.666667 0 0 1-85.333333 0V256a42.666667 42.666667 0 0 1 42.666667-42.666667zM341.333333 213.333333a42.666667 42.666667 0 0 1 42.666667 42.666667v512a42.666667 42.666667 0 0 1-85.333333 0V256a42.666667 42.666667 0 0 1 42.666666-42.666667z" fill="#000" p-id="7935"></path></svg>

After

Width:  |  Height:  |  Size: 698 B

1
src/assets/svg/play.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742561328036" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6809" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M213.333333 65.386667a85.333333 85.333333 0 0 1 43.904 12.16L859.370667 438.826667a85.333333 85.333333 0 0 1 0 146.346666L257.237333 946.453333A85.333333 85.333333 0 0 1 128 873.28V150.72a85.333333 85.333333 0 0 1 85.333333-85.333333z m0 64a21.333333 21.333333 0 0 0-21.184 18.837333L192 150.72v722.56a21.333333 21.333333 0 0 0 30.101333 19.456l2.197334-1.152L826.453333 530.282667a21.333333 21.333333 0 0 0 2.048-35.178667l-2.048-1.386667L224.298667 132.416A21.333333 21.333333 0 0 0 213.333333 129.386667z" fill="#000" p-id="6810"></path></svg>

After

Width:  |  Height:  |  Size: 883 B

41
src/stores/audio.js Normal file
View File

@@ -0,0 +1,41 @@
import { mtsAudioService } from '@/api/mts'
import { defineStore } from 'pinia'
import { ref } from 'vue'
// audio的缓存数据不持久化存储
export const audioStore = defineStore('anylink-audio', () => {
/**
* {
* objectId_01: {objectId: objectId_01, url: xxx},
* objectId_02: {objectId: objectId_02, url: xxx},
* }
*/
const audio = ref({})
/**
* 在同一个session中的audioid集合
*/
const audioInSession = ref({})
const setAudio = (sessionId, obj) => {
audio.value[obj.objectId] = obj
if (!audioInSession.value[sessionId]) {
audioInSession.value[sessionId] = []
}
audioInSession.value[sessionId].push(obj.objectId)
}
const loadAudio = async (sessionId, objectId) => {
if (!(objectId in audio.value)) {
const res = await mtsAudioService({ objectId: objectId })
setAudio(sessionId, res.data.data) // 缓存image数据
}
}
return {
audio,
audioInSession,
setAudio,
loadAudio
}
})

View File

@@ -13,3 +13,4 @@ export * from './search'
export * from './userCard'
export * from './groupCard'
export * from './image'
export * from './audio'

View File

@@ -964,7 +964,7 @@ const onSendImage = ({ objectId }) => {
}
const onSendAudio = ({ objectId }) => {
console.log('onSendAudio: ', objectId)
handleSendMessage(JSON.stringify({ type: msgContentType.AUDIO, value: `(${objectId})` }))
}
</script>

View File

@@ -1,180 +0,0 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { VideoPlay, VideoPause } from '@element-plus/icons-vue'
const props = defineProps(['audioUrl'])
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const audioContext = ref(null)
const audioBuffer = ref(null)
const analyser = ref(null)
const canvas = ref(null)
const canvasCtx = ref(null)
const animationFrame = ref(null)
const audio = ref(null)
// 格式化时间
const formatTime = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
// 初始化音频上下文和分析器
const initAudioContext = async () => {
audioContext.value = new (window.AudioContext || window.webkitAudioContext)()
analyser.value = audioContext.value.createAnalyser()
analyser.value.fftSize = 256
try {
const response = await fetch(props.audioUrl)
const arrayBuffer = await response.arrayBuffer()
audioBuffer.value = await audioContext.value.decodeAudioData(arrayBuffer)
duration.value = audioBuffer.value.duration
} catch (error) {
console.error('音频加载失败:', error)
}
}
// 绘制音频波形
const drawWaveform = () => {
if (!isPlaying.value || !canvas.value || !analyser.value) return
const bufferLength = analyser.value.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
analyser.value.getByteFrequencyData(dataArray)
canvasCtx.value.fillStyle = '#f5f5f5'
canvasCtx.value.fillRect(0, 0, canvas.value.width, canvas.value.height)
const barWidth = (canvas.value.width / bufferLength) * 2.5
let barHeight
let x = 0
for (let i = 0; i < bufferLength; i++) {
barHeight = (dataArray[i] / 255) * canvas.value.height
canvasCtx.value.fillStyle = '#409eff'
canvasCtx.value.fillRect(x, canvas.value.height - barHeight, barWidth, barHeight)
x += barWidth + 1
}
animationFrame.value = requestAnimationFrame(drawWaveform)
}
// 播放/暂停音频
const togglePlay = async () => {
if (!audioContext.value) {
await initAudioContext()
}
if (isPlaying.value) {
audioContext.value.suspend()
cancelAnimationFrame(animationFrame.value)
} else {
audioContext.value.resume()
drawWaveform()
}
isPlaying.value = !isPlaying.value
}
// 更新进度
const updateProgress = (e) => {
if (!audioBuffer.value) return
const time = e.target.value
currentTime.value = time
audio.value.currentTime = time
}
// 组件卸载时清理资源
onUnmounted(() => {
if (audioContext.value) {
audioContext.value.close()
}
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value)
}
})
onMounted(() => {
initAudioContext()
})
</script>
<template>
<div class="audio-player">
<el-button
class="play-button"
:icon="isPlaying ? VideoPause : VideoPlay"
circle
@click="togglePlay"
/>
<div class="progress-container">
<canvas ref="canvas" class="waveform" width="300" height="40"></canvas>
<el-slider
v-model="currentTime"
:max="duration"
:step="0.1"
@change="updateProgress"
class="progress-slider"
/>
</div>
<span class="time">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
</div>
</template>
<style scoped>
.audio-player {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background-color: #f5f5f5;
border-radius: 4px;
}
.play-button {
flex-shrink: 0;
}
.progress-container {
flex: 1;
position: relative;
height: 40px;
}
.waveform {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.progress-slider {
position: relative;
z-index: 2;
}
.time {
flex-shrink: 0;
font-size: 14px;
color: #606266;
min-width: 100px;
text-align: right;
}
:deep(.el-slider__runway) {
background-color: transparent;
}
:deep(.el-slider__bar) {
background-color: rgba(64, 158, 255, 0.3);
}
:deep(.el-slider__button-wrapper) {
z-index: 3;
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElIcon } from 'element-plus'
import PlayIcon from '@/assets/svg/play.svg'
import PauseIcon from '@/assets/svg/pause.svg'
import { AVWaveform } from 'vue-audio-visual'
const props = defineProps(['audioUrl'])
const emits = defineEmits(['load'])
const waveformRef = ref(null)
const isPlaying = ref(false)
const audioDuration = ref(null)
// 格式化时间
const formatDuration = computed(() => {
{
const minutes = Math.floor(audioDuration.value / 60)
const seconds = Math.floor(audioDuration.value % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
})
const playAudio = () => {
const audioPlayer = waveformRef.value.querySelector('audio')
if (audioPlayer) {
audioPlayer.play()
}
}
const pauseAudio = () => {
const audioPlayer = waveformRef.value.querySelector('audio')
if (audioPlayer) {
audioPlayer.pause()
}
}
// 播放/暂停音频
const togglePlay = async () => {
if (isPlaying.value) {
pauseAudio()
} else {
playAudio()
}
isPlaying.value = !isPlaying.value
}
onMounted(() => {
const audioPlayer = waveformRef.value.querySelector('audio')
if (audioPlayer) {
// 监听播放事件
audioPlayer.addEventListener('play', () => {
isPlaying.value = true
})
// 监听暂停事件
audioPlayer.addEventListener('pause', () => {
isPlaying.value = false
})
// 监听音频元数据加载完成事件
audioPlayer.addEventListener('loadedmetadata', () => {
audioDuration.value = audioPlayer.duration
})
}
emits('load') //触发load事件
})
</script>
<template>
<div ref="waveformRef" class="audio-player">
<div class="play-button" @click="togglePlay">
<el-icon v-if="isPlaying"><PauseIcon /></el-icon>
<el-icon v-else><PlayIcon /></el-icon>
</div>
<AVWaveform
:src="props.audioUrl"
:audio-controls="false"
:playtime="false"
:canv-width="150"
:canv-height="40"
:playtime-slider-color="`#409eff`"
></AVWaveform>
<span class="time">{{ formatDuration }}</span>
</div>
</template>
<style lang="scss" scoped>
.audio-player {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px 5px 10px;
background-color: #f5f5f5;
border-radius: 4px;
:deep(canvas) {
cursor: pointer;
}
.play-button {
padding: 8px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background-color: #fff;
cursor: pointer;
&:hover {
background-color: #c6e2ff;
color: #409eff;
}
}
.time {
font-size: 12px;
color: #606266;
text-align: center;
}
}
</style>

View File

@@ -3,12 +3,19 @@ import { computed, onMounted, h, createApp, watch, nextTick, reactive } from 'vu
import { ElImage } from 'element-plus'
import { WarningFilled } from '@element-plus/icons-vue'
import { MsgType } from '@/proto/msg'
import { userStore, messageStore, groupStore, groupCardStore, imageStore } from '@/stores'
import {
userStore,
messageStore,
groupStore,
groupCardStore,
imageStore,
audioStore
} from '@/stores'
import { messageSysShowTime, showTimeFormat, jsonParseSafe } from '@/js/utils/common'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import { emojis } from '@/js/utils/emojis'
import { msgContentType, msgSendStatus } from '@/const/msgConst'
import AudioMessage from '@/views/message/components/AudioMessage.vue'
import AudioMsgBox from '@/views/message/components/AudioMsgBox.vue'
const props = defineProps([
'sessionId',
@@ -29,6 +36,7 @@ const messageData = messageStore()
const groupData = groupStore()
const groupCardData = groupCardStore()
const imageData = imageStore()
const audioData = audioStore()
onMounted(() => {
rendering()
@@ -71,7 +79,12 @@ const renderComponent = async (content) => {
case msgContentType.TEXT:
return renderText(value)
case msgContentType.AUDIO:
return renderAudio(value)
if (value.startsWith('(') && value.endsWith(')')) {
await audioData.loadAudio(props.sessionId, value.slice(1, -1))
return renderAudio(value)
} else {
return h('span', value)
}
case msgContentType.IMAGE:
if (value.startsWith('{') && value.endsWith('}')) {
await imageData.loadImageInfoFromContent(props.sessionId, value)
@@ -91,12 +104,6 @@ const renderComponent = async (content) => {
}
}
const renderAudio = (url) => {
return h(AudioMessage, {
audioUrl: url
})
}
const renderText = (content) => {
return h('span', content)
}
@@ -196,6 +203,21 @@ const renderImage = (content) => {
}
}
const renderAudio = (content) => {
const audioId = content.slice(1, -1)
const url = audioData.audio[audioId]?.url
if (url) {
return h(AudioMsgBox, {
audioUrl: import.meta.env.VITE_OSS_CORS_FLAG + url,
onLoad: () => {
emit('loadFinished')
}
})
} else {
return h('span', content)
}
}
const msg = computed(() => {
return reactive({ ...messageData.getMsg(props.sessionId, props.msgId) })
})