|
|
|
@ -194,16 +194,14 @@
|
|
|
|
|
/>
|
|
|
|
|
<button @click="sendMessage" :disabled="!newMessage.trim()" class="send-button">发送</button>
|
|
|
|
|
|
|
|
|
|
<!-- 语音消息按钮 -->
|
|
|
|
|
<!-- 语音消息按钮 - 改为开关形式 -->
|
|
|
|
|
<button
|
|
|
|
|
@mousedown="startRecording"
|
|
|
|
|
@mouseup="stopRecording"
|
|
|
|
|
@mouseleave="cancelRecording"
|
|
|
|
|
@click="toggleRecording"
|
|
|
|
|
class="voice-button"
|
|
|
|
|
:class="{ 'recording': isRecording }"
|
|
|
|
|
title="按住说话"
|
|
|
|
|
:title="isRecording ? '点击结束录音' : '点击开始录音'"
|
|
|
|
|
>
|
|
|
|
|
<i class="mic-icon">🎤</i>
|
|
|
|
|
<i class="mic-icon">{{ isRecording ? '⏹️' : '🎤' }}</i>
|
|
|
|
|
<span v-if="isRecording" class="recording-text">正在录音... {{ recordingDuration }}s</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
@ -480,59 +478,8 @@ export default {
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// WebRTC语音聊天事件
|
|
|
|
|
socket.on('voice_chat_event', (data) => {
|
|
|
|
|
console.log('收到语音聊天事件:', data)
|
|
|
|
|
|
|
|
|
|
const { type, user_id, username } = data
|
|
|
|
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'user_joined': {
|
|
|
|
|
// 有新用户加入语音聊天
|
|
|
|
|
addNotification(`${username} 加入了语音聊天`)
|
|
|
|
|
|
|
|
|
|
// 将用户添加到参与者列表
|
|
|
|
|
if (!rtcParticipants.value.find(p => p.id === user_id)) {
|
|
|
|
|
rtcParticipants.value.push({
|
|
|
|
|
id: user_id,
|
|
|
|
|
username: username,
|
|
|
|
|
muted: false
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果自己在语音聊天中,发起连接
|
|
|
|
|
if (rtcActive.value && localStream.value) {
|
|
|
|
|
// 通知新用户有新的对等连接
|
|
|
|
|
sendSignal(user_id, 'new_peer', {})
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'user_left': {
|
|
|
|
|
// 用户离开语音聊天
|
|
|
|
|
addNotification(`${username} 离开了语音聊天`)
|
|
|
|
|
|
|
|
|
|
// 从参与者列表中移除
|
|
|
|
|
rtcParticipants.value = rtcParticipants.value.filter(p => p.id !== user_id)
|
|
|
|
|
|
|
|
|
|
// 关闭与该用户的对等连接
|
|
|
|
|
closePeerConnection(user_id)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'mute_change': {
|
|
|
|
|
// 用户改变静音状态
|
|
|
|
|
const mutedStatus = data.muted
|
|
|
|
|
const participant = rtcParticipants.value.find(p => p.id === user_id)
|
|
|
|
|
|
|
|
|
|
if (participant) {
|
|
|
|
|
participant.muted = mutedStatus
|
|
|
|
|
addNotification(`${username} ${mutedStatus ? '已静音' : '已取消静音'}`)
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 获取聊天室列表
|
|
|
|
@ -646,10 +593,37 @@ export default {
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 使用更高效的编码配置
|
|
|
|
|
const options = {
|
|
|
|
|
mimeType: 'audio/webm;codecs=opus',
|
|
|
|
|
audioBitsPerSecond: 32000 // 降低比特率以减小文件大小
|
|
|
|
|
// 检测浏览器支持的音频格式
|
|
|
|
|
let options = {};
|
|
|
|
|
let mimeType = '';
|
|
|
|
|
|
|
|
|
|
// 按优先级检测支持的格式
|
|
|
|
|
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
|
|
|
|
mimeType = 'audio/webm;codecs=opus';
|
|
|
|
|
console.log('使用 audio/webm;codecs=opus 格式录音');
|
|
|
|
|
}
|
|
|
|
|
else if (MediaRecorder.isTypeSupported('audio/webm')) {
|
|
|
|
|
mimeType = 'audio/webm';
|
|
|
|
|
console.log('使用 audio/webm 格式录音');
|
|
|
|
|
}
|
|
|
|
|
else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
|
|
|
|
mimeType = 'audio/ogg;codecs=opus';
|
|
|
|
|
console.log('使用 audio/ogg;codecs=opus 格式录音');
|
|
|
|
|
}
|
|
|
|
|
else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
|
|
|
|
mimeType = 'audio/mp4';
|
|
|
|
|
console.log('使用 audio/mp4 格式录音');
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
console.log('使用默认音频格式录音');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 设置录音选项
|
|
|
|
|
if (mimeType) {
|
|
|
|
|
options = {
|
|
|
|
|
mimeType: mimeType,
|
|
|
|
|
audioBitsPerSecond: 32000 // 降低比特率以减小文件大小
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 创建媒体录制器
|
|
|
|
@ -682,7 +656,7 @@ export default {
|
|
|
|
|
console.log('录音实际时长:', actualDuration, '秒')
|
|
|
|
|
|
|
|
|
|
// 将录制的音频块转换为Blob
|
|
|
|
|
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' })
|
|
|
|
|
const audioBlob = new Blob(audioChunks.value, { type: mimeType || 'audio/webm' })
|
|
|
|
|
|
|
|
|
|
// 添加本地预览消息,减少感知延迟
|
|
|
|
|
const tempId = 'temp-' + Date.now()
|
|
|
|
@ -706,12 +680,14 @@ export default {
|
|
|
|
|
reader.readAsDataURL(audioBlob)
|
|
|
|
|
reader.onloadend = () => {
|
|
|
|
|
const base64Audio = reader.result
|
|
|
|
|
console.log('音频格式:', mimeType || 'unknown', '数据前缀:', base64Audio.substring(0, 50))
|
|
|
|
|
|
|
|
|
|
// 发送语音消息
|
|
|
|
|
socket.emit('send_audio_message', {
|
|
|
|
|
room_id: currentRoom.value.id,
|
|
|
|
|
audio_data: base64Audio,
|
|
|
|
|
audio_duration: actualDuration
|
|
|
|
|
audio_duration: actualDuration,
|
|
|
|
|
audio_mime_type: mimeType || 'audio/webm'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 重置录音状态
|
|
|
|
@ -744,6 +720,15 @@ export default {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 切换录音状态
|
|
|
|
|
const toggleRecording = () => {
|
|
|
|
|
if (isRecording.value) {
|
|
|
|
|
stopRecording()
|
|
|
|
|
} else {
|
|
|
|
|
startRecording()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 停止录音
|
|
|
|
|
const stopRecording = () => {
|
|
|
|
|
if (!mediaRecorder.value || mediaRecorder.value.state === 'inactive') {
|
|
|
|
@ -799,24 +784,53 @@ export default {
|
|
|
|
|
console.log('准备播放语音消息:', message.id, '类型:', typeof message.message);
|
|
|
|
|
|
|
|
|
|
// 确保音频源是有效的
|
|
|
|
|
let audioSource = null;
|
|
|
|
|
const mimeType = message.audio_mime_type || 'audio/webm';
|
|
|
|
|
|
|
|
|
|
if (message.localAudioUrl && message.localAudioUrl.startsWith('blob:')) {
|
|
|
|
|
// 优先使用本地缓存的Blob URL
|
|
|
|
|
audioPlayer.value.src = message.localAudioUrl;
|
|
|
|
|
audioSource = message.localAudioUrl;
|
|
|
|
|
console.log('使用本地缓存的Blob URL播放');
|
|
|
|
|
} else if (message.message.startsWith('blob:')) {
|
|
|
|
|
// 本地消息使用Blob URL
|
|
|
|
|
audioPlayer.value.src = message.message;
|
|
|
|
|
audioSource = message.message;
|
|
|
|
|
console.log('使用Blob URL播放:', message.message);
|
|
|
|
|
} else if (message.message.startsWith('data:audio')) {
|
|
|
|
|
// 服务器返回的base64音频数据
|
|
|
|
|
audioPlayer.value.src = message.message;
|
|
|
|
|
audioSource = message.message;
|
|
|
|
|
console.log('使用base64数据播放');
|
|
|
|
|
} else {
|
|
|
|
|
// 尝试将消息转换为有效的音频源
|
|
|
|
|
console.warn('未知格式的音频消息,尝试直接使用:', message.message.substring(0, 50) + '...');
|
|
|
|
|
audioPlayer.value.src = message.message;
|
|
|
|
|
console.warn('未知格式的音频消息,尝试修复格式');
|
|
|
|
|
|
|
|
|
|
// 尝试修复可能的格式问题
|
|
|
|
|
try {
|
|
|
|
|
// 检查是否是有效的base64数据但缺少头部
|
|
|
|
|
if (typeof message.message === 'string') {
|
|
|
|
|
// 尝试检测是否为base64编码
|
|
|
|
|
const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
|
|
|
const cleanedData = message.message.replace(/^data:.*?;base64,/, '');
|
|
|
|
|
|
|
|
|
|
if (base64Regex.test(cleanedData)) {
|
|
|
|
|
// 添加适当的MIME类型头
|
|
|
|
|
audioSource = `data:${mimeType};base64,` + cleanedData;
|
|
|
|
|
console.log(`修复为带头部的base64数据,使用MIME类型: ${mimeType}`);
|
|
|
|
|
} else {
|
|
|
|
|
// 可能不是base64,直接使用
|
|
|
|
|
audioSource = message.message;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
audioSource = message.message;
|
|
|
|
|
}
|
|
|
|
|
} catch (formatErr) {
|
|
|
|
|
console.error('修复音频格式失败:', formatErr);
|
|
|
|
|
audioSource = message.message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 设置音频源
|
|
|
|
|
audioPlayer.value.src = audioSource;
|
|
|
|
|
|
|
|
|
|
// 设置当前播放状态
|
|
|
|
|
currentPlayingId.value = message.id;
|
|
|
|
|
isPlaying.value = true;
|
|
|
|
@ -830,8 +844,39 @@ export default {
|
|
|
|
|
|
|
|
|
|
// 播放出错时重置状态
|
|
|
|
|
audioPlayer.value.onerror = (e) => {
|
|
|
|
|
console.error('音频播放出错:', e);
|
|
|
|
|
alert('播放语音失败,可能是格式不支持或数据已损坏');
|
|
|
|
|
const errorDetails = e.target.error ? `错误代码: ${e.target.error.code}` : '未知错误';
|
|
|
|
|
console.error('音频播放出错:', errorDetails);
|
|
|
|
|
|
|
|
|
|
// 尝试使用不同的MIME类型重新播放
|
|
|
|
|
if (!message.retryCount) {
|
|
|
|
|
message.retryCount = 1;
|
|
|
|
|
console.log('尝试使用备用MIME类型重新播放');
|
|
|
|
|
|
|
|
|
|
// 清除当前音频源
|
|
|
|
|
audioPlayer.value.src = '';
|
|
|
|
|
|
|
|
|
|
// 尝试不同的MIME类型
|
|
|
|
|
const alternativeMimeTypes = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/wav'];
|
|
|
|
|
const currentMimeType = mimeType;
|
|
|
|
|
|
|
|
|
|
// 找到一个不同的MIME类型
|
|
|
|
|
let newMimeType = alternativeMimeTypes.find(type => type !== currentMimeType) || 'audio/webm';
|
|
|
|
|
|
|
|
|
|
// 使用新的MIME类型
|
|
|
|
|
message.audio_mime_type = newMimeType;
|
|
|
|
|
|
|
|
|
|
// 重新尝试播放
|
|
|
|
|
setTimeout(() => playAudio(message), 100);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果是本地录制的消息,提示可能是暂时性问题
|
|
|
|
|
if (message.isLocal) {
|
|
|
|
|
alert('播放本地录制的语音失败,这可能是暂时性问题,语音消息仍会发送给其他用户');
|
|
|
|
|
} else {
|
|
|
|
|
alert('播放语音失败,可能是格式不支持或数据已损坏');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentPlayingId.value = null;
|
|
|
|
|
isPlaying.value = false;
|
|
|
|
|
};
|
|
|
|
@ -930,7 +975,7 @@ export default {
|
|
|
|
|
// 1. 获取媒体设备列表
|
|
|
|
|
await getMediaDevices()
|
|
|
|
|
|
|
|
|
|
// 2. 请求麦克风权限并获取音频流
|
|
|
|
|
// 2. 请求麦克风权限并获取音频流(静音状态)
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
audio: {
|
|
|
|
|
deviceId: selectedAudioInput.value ? { exact: selectedAudioInput.value } : undefined,
|
|
|
|
@ -941,6 +986,12 @@ export default {
|
|
|
|
|
video: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 默认静音开始
|
|
|
|
|
stream.getAudioTracks().forEach(track => {
|
|
|
|
|
track.enabled = false
|
|
|
|
|
})
|
|
|
|
|
isMuted.value = true
|
|
|
|
|
|
|
|
|
|
localStream.value = stream
|
|
|
|
|
|
|
|
|
|
// 3. 设置音频分析器(用于显示音量)
|
|
|
|
@ -953,7 +1004,7 @@ export default {
|
|
|
|
|
|
|
|
|
|
// 5. 标记WebRTC为活跃状态
|
|
|
|
|
rtcActive.value = true
|
|
|
|
|
addNotification('您已加入语音聊天')
|
|
|
|
|
addNotification('您已加入语音聊天(已静音)')
|
|
|
|
|
|
|
|
|
|
isLoadingRTC.value = false
|
|
|
|
|
} catch (err) {
|
|
|
|
@ -1345,6 +1396,7 @@ export default {
|
|
|
|
|
audioPlayer,
|
|
|
|
|
isRecording,
|
|
|
|
|
recordingDuration,
|
|
|
|
|
rtcActive,
|
|
|
|
|
localStream,
|
|
|
|
|
peerConnections,
|
|
|
|
|
mediaDevices,
|
|
|
|
@ -1360,7 +1412,7 @@ export default {
|
|
|
|
|
joinRoom,
|
|
|
|
|
leaveRoom,
|
|
|
|
|
sendMessage,
|
|
|
|
|
startRecording,
|
|
|
|
|
toggleRecording,
|
|
|
|
|
stopRecording,
|
|
|
|
|
cancelRecording,
|
|
|
|
|
playAudio,
|
|
|
|
@ -1369,7 +1421,7 @@ export default {
|
|
|
|
|
isCurrentUser,
|
|
|
|
|
formatTime,
|
|
|
|
|
toggleVoiceChat,
|
|
|
|
|
rtcActive
|
|
|
|
|
toggleMute
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@ -1836,9 +1888,6 @@ export default {
|
|
|
|
|
|
|
|
|
|
.recording-text {
|
|
|
|
|
position: absolute;
|
|
|
|
|
left: -120px;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #e74c3c;
|
|
|
|
@ -1848,6 +1897,9 @@ export default {
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
|
|
z-index: 10;
|
|
|
|
|
left: -120px;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 在空间不足时的备选位置 */
|
|
|
|
|