diff --git a/.gitignore b/.gitignore index d600b6c..3e00e6d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.sln *.sw? +/dist.zip diff --git a/src/api/modules/call.ts b/src/api/modules/call.ts index 487ab75..4992793 100644 --- a/src/api/modules/call.ts +++ b/src/api/modules/call.ts @@ -14,7 +14,7 @@ export interface PullURLInfo { // 参与者信息 export interface ParticipantInfo { user_id: string - platform: 'h5' | 'app' | 'miniprogram' + platform: 'h5' | 'web' | 'app' | 'miniprogram' | 'wxapp' has_audio: boolean has_video: boolean } @@ -30,7 +30,7 @@ export interface ICEServerConfig { export interface JoinCallRoomRequest { room_id: string user_id: string - platform: 'h5' | 'app' | 'miniprogram' + platform: 'h5' | 'web' | 'app' | 'miniprogram' | 'wxapp' } // 加入通话房间响应 @@ -39,6 +39,7 @@ export interface JoinCallRoomResponse { platform: string ice_servers?: ICEServerConfig[] ws_push_url?: string // H5/App 用 RTMP 模式 WebSocket 推流地址 + self_flv_url?: string // H5/App 用 - 自己的 HTTP-FLV 地址(供小程序拉流) flv_pull_urls?: PullURLInfo[] // H5/App 拉取小程序流的 FLV 地址 push_url?: string // 小程序用 RTMP 推流地址 pull_urls?: PullURLInfo[] // 小程序用 RTMP 拉流地址 diff --git a/src/components/call/CallWindow.vue b/src/components/call/CallWindow.vue index 7f7af07..b89e47c 100644 --- a/src/components/call/CallWindow.vue +++ b/src/components/call/CallWindow.vue @@ -400,12 +400,19 @@ watch( onUnmounted(() => { if (flvPlayer) { try { - flvPlayer.pause() + // 检查播放器状态,避免空指针错误 + // @ts-ignore - flv.js Player 内部属性 + if (flvPlayer._mediaElement) { + flvPlayer.pause() + } flvPlayer.unload() flvPlayer.detachMediaElement() flvPlayer.destroy() } catch (e) { - console.error('[CallWindow] 销毁 FLV 播放器失败:', e) + // 忽略已销毁的播放器错误 + if (!(e instanceof TypeError && (e as Error).message?.includes("Cannot read properties of null"))) { + console.error('[CallWindow] 销毁 FLV 播放器失败:', e) + } } flvPlayer = null } diff --git a/src/composables/useWebRTC.ts b/src/composables/useWebRTC.ts index 74ebbc2..ecfeffd 100644 --- a/src/composables/useWebRTC.ts +++ b/src/composables/useWebRTC.ts @@ -6,7 +6,7 @@ import * as callApi from '@/api/modules/call' import { wsManager } from '@/api/websocket' import { useToastStore } from '@/stores/toast' import { useChatStore } from '@/stores/chat' -import { isRTMPMode } from '@/config/call' +import { isRTMPMode, isAutoMode, shouldUseRTMP, parsePlatform, type Platform } from '@/config/call' import type { ChatMessage, Contact, ICEServerConfig } from '@/types/api' import type { CallStatus } from '@/types/message' @@ -68,6 +68,10 @@ export function useWebRTC( const pendingCandidates: RTCIceCandidate[] = [] let currentReceiverUserId = '' let currentRoomId = '' + + // Auto 模式:跟踪对方平台 + let remotePlatform: Platform = 'unknown' + const useRTMP = ref(false) // 当前通话是否使用 RTMP 模式 // RTMP 模式相关 let wsPushSocket: WebSocket | null = null @@ -85,12 +89,21 @@ export function useWebRTC( // 【关键修复】如果是群聊信令,直接忽略! // 通过判断是否存在 callRoomId 或 participantIds 来识别 if (content.callRoomId || content.participantIds) { + console.log('[WebRTC] 忽略群聊信令') return } const signal = message.call_status as any const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {} + console.log('[WebRTC] 📨 收到信令:', signal, { + from: message.sender_user_id, + room_id: message.room_id, + call_id: message.call_id, + content: content, + extra: extra + }) + if (extra.type) call.type = extra.type if (signal === 'sync_state') { @@ -100,7 +113,23 @@ export function useWebRTC( } if (signal === 'invite') { - if (call.active) return + // Auto 模式:检测对方平台,决定使用 WebRTC 还是 RTMP + remotePlatform = parsePlatform(extra.platform) + useRTMP.value = shouldUseRTMP(remotePlatform) + + console.log('[WebRTC] 📞 收到来电邀请:', { + from: message.sender_user_id, + room_id: message.room_id, + call_id: message.call_id, + type: extra.type, + platform: extra.platform, + remotePlatform, + useRTMP: useRTMP.value + }) + if (call.active) { + console.log('[WebRTC] ⚠️ 当前有通话,忽略新来电') + return + } isCaller.value = false currentReceiverUserId = message.sender_user_id if (message.room_id) currentRoomId = message.room_id @@ -109,55 +138,114 @@ export function useWebRTC( call.minimized = false call.status = 'incoming' call.statusText = `邀请你通话` + console.log('[WebRTC] ✅ 设置来电状态, roomId:', currentRoomId, 'mode:', useRTMP.value ? 'RTMP' : 'WebRTC') playRingtone('incoming') if (onIncomingCall) onIncomingCall(message.sender_user_id) } else if (signal === 'accepted') { + // Auto 模式:从 accepted 信令中检测对方平台,决定最终使用的模式 + const previousMode = useRTMP.value + if (extra.platform) { + remotePlatform = parsePlatform(extra.platform) + useRTMP.value = shouldUseRTMP(remotePlatform) + console.log('[WebRTC] 📱 对方平台:', extra.platform, '→', remotePlatform, + '| 模式:', useRTMP.value ? 'RTMP' : 'WebRTC', + previousMode !== useRTMP.value ? '(已切换)' : '') + } else { + // 如果没有 platform 信息,默认认为对方是 h5,使用 WebRTC + remotePlatform = 'h5' + useRTMP.value = false + console.log('[WebRTC] ⚠️ 对方未提供平台信息,默认为 h5,使用 WebRTC') + } + + console.log('[WebRTC] ✅ 对方已接听, 最终模式:', useRTMP.value ? 'RTMP' : 'WebRTC') stopRingtone() call.status = 'connected' call.statusText = '通话中' startCallTimer() // 加入房间获取小程序用户的 FLV 流地址(RTMP 模式下也会启动 WebSocket 推流) + console.log('[WebRTC] 📡 准备加入房间, roomId:', currentRoomId, 'mode:', useRTMP.value ? 'RTMP' : 'WebRTC') await joinCallRoomAndGetFlvUrls() - // WebRTC 模式:创建 Offer - if (!isRTMPMode()) { + // WebRTC 模式:创建 Offer(只有 useRTMP 为 false 时才使用 WebRTC) + if (!useRTMP.value) { + console.log('[WebRTC] WebRTC 模式:创建 Offer') if (!pc) await createPC() const offer = await pc!.createOffer() await pc!.setLocalDescription(offer) sendSignal('offer', offer) + } else { + console.log('[WebRTC] RTMP 模式:等待 FLV 流') } } else if (signal === 'offer') { - // WebRTC 模式才处理 offer - if (!isRTMPMode()) { - stopRingtone() - if (!pc) { - await initMedia(call.type === 'video') - await createPC() - } - await pc!.setRemoteDescription(content) - processPendingCandidates() - const answer = await pc!.createAnswer() - await pc!.setLocalDescription(answer) - sendSignal('answer', answer, message.sender_user_id) - call.status = 'connected' - call.statusText = '通话中' - startCallTimer() + // WebRTC 模式才处理 offer(RTMP 模式不使用 WebRTC 信令) + if (!useRTMP.value) { + stopRingtone() + if (!pc) { + await initMedia(call.type === 'video') + await createPC() + } + await pc!.setRemoteDescription(content) + processPendingCandidates() + const answer = await pc!.createAnswer() + await pc!.setLocalDescription(answer) + sendSignal('answer', answer, message.sender_user_id) + call.status = 'connected' + call.statusText = '通话中' + startCallTimer() } } else if (signal === 'answer') { - // WebRTC 模式才处理 answer - if (!isRTMPMode() && pc) { + // WebRTC 模式才处理 answer(RTMP 模式不使用 WebRTC 信令) + if (!useRTMP.value && pc) { await pc.setRemoteDescription(content) processPendingCandidates() } } else if (signal === 'candidate') { - // WebRTC 模式才处理 candidate - if (!isRTMPMode()) { - if (pc && pc.remoteDescription) await pc.addIceCandidate(content) - else pendingCandidates.push(content) + // WebRTC 模式才处理 candidate(RTMP 模式不使用 WebRTC 信令) + if (!useRTMP.value) { + if (pc && pc.remoteDescription) await pc.addIceCandidate(content) + else pendingCandidates.push(content) + } + + } else if (signal === 'participant_joined') { + // 新参与者加入房间(来自小程序端) + console.log('[WebRTC] 🆕 收到 participant_joined 信令:', { + user_id: content.user_id, + platform: content.platform, + flv_url: content.flv_url, + pull_url: content.pull_url + }) + + // 如果有 FLV 地址,添加或更新小程序流列表 + if (content.flv_url && content.user_id) { + const existingIndex = call.miniprogramStreams.findIndex(s => s.userId === content.user_id) + if (existingIndex >= 0) { + // 更新现有流的地址(重要:当用户重新加入时使用新的流地址) + const oldFlvUrl = call.miniprogramStreams[existingIndex].flvUrl + call.miniprogramStreams[existingIndex].flvUrl = content.flv_url + console.log('[WebRTC] 🔄 更新小程序流地址:', content.user_id) + console.log('[WebRTC] 旧地址:', oldFlvUrl) + console.log('[WebRTC] 新地址:', content.flv_url) + } else { + // 添加新的小程序流 + call.miniprogramStreams.push({ + userId: content.user_id, + flvUrl: content.flv_url + }) + console.log('[WebRTC] ✅ 添加小程序流:', content.user_id, content.flv_url) + } + console.log('[WebRTC] 📺 当前小程序流列表:', call.miniprogramStreams) + } else { + console.log('[WebRTC] ⚠️ participant_joined 没有 FLV 地址') + } + + // 如果当前没有小程序流,尝试重新获取房间信息 + if (call.miniprogramStreams.length === 0 && currentRoomId) { + console.log('[WebRTC] 📡 没有小程序流,尝试刷新房间信息...') + refreshRoomStreams() } } else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) { @@ -342,10 +430,19 @@ export function useWebRTC( } function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) { - if (!call.id) return + if (!call.id) { + console.warn('[WebRTC] ⚠️ sendSignal: call.id 为空,跳过') + return + } const targetUserId = receiverUserId || currentReceiverUserId const roomId = getSafeRoomId(targetUserId) - if (!roomId) return + if (!roomId) { + console.warn('[WebRTC] ⚠️ sendSignal: roomId 为空,跳过') + return + } + + console.log('[WebRTC] 📤 发送信令:', status, { to: targetUserId, roomId, callId: call.id }) + const payload = { sender_client_id: wsManager.getClientId() || '', receiver_user_id: targetUserId, @@ -354,9 +451,12 @@ export function useWebRTC( content: JSON.stringify(data || {}), call_id: call.id, call_status: status, - extra: JSON.stringify({ type: call.type }), + // 包含平台信息,让对方知道我们是 web 端 + extra: JSON.stringify({ type: call.type, platform: 'web' }), } - messageApi.sendMessage(payload).catch(console.error) + messageApi.sendMessage(payload).catch(err => { + console.error('[WebRTC] ❌ 发送信令失败:', err) + }) } function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) { @@ -377,6 +477,8 @@ export function useWebRTC( } async function startCall(type: 'audio' | 'video', receiverUserId: string, roomId?: string, contact?: Contact) { + console.log('[WebRTC] 📞 发起通话:', { type, receiverUserId, roomId, contactRoomId: contact?.room_id }) + if (roomId) currentRoomId = roomId else if (contact?.room_id) currentRoomId = contact.room_id else if (getRoomId) { @@ -385,12 +487,27 @@ export function useWebRTC( } if (!currentRoomId) { + console.error('[WebRTC] ❌ 缺少房间信息') toastStore.error('无法建立通话连接:缺少房间信息') return false } + // Auto 模式:发起通话时,默认使用 WebRTC,收到 accepted 后根据对方平台调整 + // 如果对方是小程序,会在 accepted 处理时切换到 RTMP + // 固定模式:使用配置的模式 + if (isAutoMode()) { + // Auto 模式下,发起方先假设使用 WebRTC(Web-to-Web 最优) + // 收到 accepted 后,如果对方是小程序才切换到 RTMP + useRTMP.value = false + console.log('[WebRTC] ✅ 房间ID:', currentRoomId, 'Auto 模式:发起通话,默认 WebRTC,等待对方平台信息') + } else { + useRTMP.value = isRTMPMode() + console.log('[WebRTC] ✅ 房间ID:', currentRoomId, '固定模式:', useRTMP.value ? 'RTMP' : 'WebRTC') + } + isCaller.value = true currentReceiverUserId = receiverUserId + remotePlatform = 'unknown' // 重置远程平台 call.type = type call.id = Date.now().toString() call.active = true @@ -404,21 +521,25 @@ export function useWebRTC( remoteStream.value = null try { + console.log('[WebRTC] 🎥 初始化媒体设备...') await initMedia(type === 'video') + console.log('[WebRTC] ✅ 媒体设备初始化成功') - // 根据模式选择不同的推流方式 - if (isRTMPMode()) { - console.log('[WebRTC] 使用 RTMP 模式') + // 根据当前模式选择不同的推流方式 + if (useRTMP.value) { + console.log('[WebRTC] 📡 使用 RTMP 模式(auto 模式下可能在 accepted 后切换)') // RTMP 模式:加入房间后会自动开始 WebSocket 推流 } else { - console.log('[WebRTC] 使用 WebRTC 模式') + console.log('[WebRTC] 📡 使用 WebRTC 模式') await createPC() } playRingtone('dialing') + console.log('[WebRTC] 📤 发送 invite 信令给:', receiverUserId) sendSignal('invite', undefined, receiverUserId) return true } catch (error: any) { + console.error('[WebRTC] ❌ 发起通话失败:', error) toastStore.error(error.message || '无法启动通话') closeCall() return false @@ -426,6 +547,9 @@ export function useWebRTC( } async function acceptCall(senderUserId?: string) { + // 接听时 useRTMP 已经在 invite 处理时设置好了 + console.log('[WebRTC] 📞 接听来电:', { senderUserId, currentRoomId, mode: useRTMP.value ? 'RTMP' : 'WebRTC' }) + isCaller.value = false stopRingtone() if (senderUserId) currentReceiverUserId = senderUserId @@ -435,20 +559,24 @@ export function useWebRTC( call.remoteCamOff = false try { + console.log('[WebRTC] 🎥 初始化媒体设备...') await initMedia(call.type === 'video') + console.log('[WebRTC] ✅ 媒体设备初始化成功') - // 根据模式选择不同的推流方式 - if (isRTMPMode()) { - console.log('[WebRTC] 接听:使用 RTMP 模式') + // 根据模式选择不同的推流方式(useRTMP 在收到 invite 时已确定) + if (useRTMP.value) { + console.log('[WebRTC] 📡 接听:使用 RTMP 模式,准备加入房间') // RTMP 模式:加入房间后会自动开始 WebSocket 推流 await joinCallRoomAndGetFlvUrls() } else { - console.log('[WebRTC] 接听:使用 WebRTC 模式') + console.log('[WebRTC] 📡 接听:使用 WebRTC 模式') await createPC() } + console.log('[WebRTC] 📤 发送 accepted 信令给:', currentReceiverUserId) sendSignal('accepted', undefined, currentReceiverUserId) } catch (error) { + console.error('[WebRTC] ❌ 接听失败:', error) endCall() } } @@ -500,37 +628,137 @@ export function useWebRTC( * 加入通话房间并获取小程序用户的 FLV 流地址 */ async function joinCallRoomAndGetFlvUrls(): Promise { + console.log('[WebRTC] 🚪 准备加入房间:', { roomId: currentRoomId, userId }) + + if (!currentRoomId) { + console.error('[WebRTC] ❌ joinCallRoomAndGetFlvUrls: currentRoomId 为空,跳过') + return + } + + try { + console.log('[WebRTC] 📤 发送加入房间请求...') + const response = await callApi.joinCallRoom({ + room_id: currentRoomId, + user_id: userId, + platform: 'web' + }) + + console.log('[WebRTC] ✅ 加入房间响应:', JSON.stringify(response, null, 2)) + + // 检查是否有小程序用户的 FLV 流 + console.log('[WebRTC] 📺 检查 FLV 拉流地址:', response.flv_pull_urls) + if (response.flv_pull_urls && response.flv_pull_urls.length > 0) { + console.log('[WebRTC] ✅ 检测到小程序用户流:', response.flv_pull_urls.length, '个') + call.miniprogramStreams = response.flv_pull_urls.map(p => ({ + userId: p.user_id, + flvUrl: p.flv_url || '' + })).filter(s => s.flvUrl) + console.log('[WebRTC] 📺 已添加小程序流:', call.miniprogramStreams) + } else { + console.log('[WebRTC] ⚠️ 没有检测到小程序用户流') + } + + // 检查参与者列表 + console.log('[WebRTC] 👥 房间参与者:', response.participants) + + // RTMP 模式:获取 WebSocket 推流地址并开始推流 + if (useRTMP.value && response.ws_push_url) { + wsPushUrl.value = response.ws_push_url + console.log('[WebRTC] RTMP 模式,WebSocket 推流地址:', response.ws_push_url) + + // 确保 localStream 存在再推流,增加重试机制 + const tryStartPush = (retryCount = 0) => { + if (localStream.value) { + console.log('[WebRTC] ✅ localStream 就绪,开始 WebSocket 推流') + startWebSocketPush(response.ws_push_url!) + } else if (retryCount < 10) { + console.warn(`[WebRTC] ⚠️ localStream 未就绪,500ms 后重试 (${retryCount + 1}/10)`) + setTimeout(() => tryStartPush(retryCount + 1), 500) + } else { + console.error('[WebRTC] ❌ localStream 始终未就绪,放弃推流') + } + } + tryStartPush() + } + + // 关键修复:通知房间内其他参与者"我已加入" + // 发送 participant_joined 信令,让小程序端知道可以拉取我的流 + sendParticipantJoinedSignal(response) + + } catch (error) { + console.error('[WebRTC] 加入房间失败:', error) + } + } + + /** + * 发送 participant_joined 信令通知其他参与者 + */ + function sendParticipantJoinedSignal(response: callApi.JoinCallRoomResponse) { + console.log('[WebRTC] 📤 准备发送 participant_joined 信令:', { callId: call.id, roomId: currentRoomId, to: currentReceiverUserId }) + + if (!call.id || !currentRoomId) { + console.warn('[WebRTC] ⚠️ sendParticipantJoinedSignal: call.id 或 roomId 为空,跳过') + return + } + + // 构建参与者信息,包含自己的 FLV 地址供小程序拉流 + const participantInfo = { + user_id: userId, + platform: 'web', + // Web 端的 FLV 地址(供小程序拉流) + pull_url: response.ws_push_url || '', // WebSocket 推流地址(仅供参考) + flv_url: response.self_flv_url || '', // HTTP-FLV 地址(供小程序 live-player 播放) + } + + console.log('[WebRTC] 📤 发送 participant_joined 信令:', participantInfo, 'to:', currentReceiverUserId) + + const payload = { + sender_client_id: wsManager.getClientId() || '', + receiver_user_id: currentReceiverUserId, + room_id: currentRoomId, + message_type: 6, + content: JSON.stringify(participantInfo), + call_id: call.id, + call_status: 'participant_joined' as any, + extra: JSON.stringify({ type: call.type, platform: 'web' }), + } + messageApi.sendMessage(payload).then(() => { + console.log('[WebRTC] ✅ participant_joined 信令发送成功') + }).catch(err => { + console.error('[WebRTC] ❌ participant_joined 信令发送失败:', err) + }) + } + + /** + * 刷新房间流信息(当收到 participant_joined 但没有流地址时) + */ + async function refreshRoomStreams() { if (!currentRoomId) return try { const response = await callApi.joinCallRoom({ room_id: currentRoomId, user_id: userId, - platform: 'h5' + platform: 'web' }) - console.log('[WebRTC] 加入房间响应:', response) + console.log('[WebRTC] 刷新房间流信息:', response) - // 检查是否有小程序用户的 FLV 流 + // 更新小程序流列表 if (response.flv_pull_urls && response.flv_pull_urls.length > 0) { - console.log('[WebRTC] 检测到小程序用户流:', response.flv_pull_urls) - call.miniprogramStreams = response.flv_pull_urls.map(p => ({ - userId: p.user_id, - flvUrl: p.flv_url || '' - })).filter(s => s.flvUrl) - } - - // RTMP 模式:获取 WebSocket 推流地址并开始推流 - if (isRTMPMode() && response.ws_push_url) { - wsPushUrl.value = response.ws_push_url - console.log('[WebRTC] RTMP模式,WebSocket推流地址:', response.ws_push_url) - // 开始 WebSocket 推流 - if (localStream.value) { - startWebSocketPush(response.ws_push_url) + for (const p of response.flv_pull_urls) { + const exists = call.miniprogramStreams.some(s => s.userId === p.user_id) + if (!exists && p.flv_url) { + call.miniprogramStreams.push({ + userId: p.user_id, + flvUrl: p.flv_url + }) + console.log('[WebRTC] 刷新后添加小程序流:', p.user_id, p.flv_url) + } } } } catch (error) { - console.error('[WebRTC] 加入房间失败:', error) + console.error('[WebRTC] 刷新房间流信息失败:', error) } } @@ -575,6 +803,8 @@ export function useWebRTC( /** * 开始 MediaRecorder 录制 + * 优先使用 H.264 编码,因为 H.264 可以直接封装到 FLV,无需转码 + * 如果浏览器不支持 H.264,则降级到 VP8/VP9(需要服务器端 FFmpeg 转码) */ function startMediaRecorder(): void { if (!localStream.value || !wsPushSocket) { @@ -582,17 +812,29 @@ export function useWebRTC( } // 检查浏览器支持的 MIME 类型 + // 优先级:H.264 > VP8 > VP9 > 默认 + // H.264 可以直接重封装为 FLV,VP8/VP9 需要 FFmpeg 转码 const mimeTypes = [ - 'video/webm;codecs=vp8,opus', - 'video/webm;codecs=vp9,opus', - 'video/webm;codecs=h264,opus', - 'video/webm', + 'video/webm;codecs=h264,opus', // 优先:H.264 可直接封装 FLV + 'video/webm;codecs=avc1,opus', // 备选:H.264 另一种写法 + 'video/webm;codecs=vp8,opus', // 降级:需要 FFmpeg 转码 + 'video/webm;codecs=vp9,opus', // 降级:需要 FFmpeg 转码 + 'video/webm', // 最后兜底 ] let selectedMimeType = '' + let selectedCodec = 'unknown' for (const mimeType of mimeTypes) { if (MediaRecorder.isTypeSupported(mimeType)) { selectedMimeType = mimeType + // 解析出视频编解码器 + if (mimeType.includes('h264') || mimeType.includes('avc1')) { + selectedCodec = 'h264' + } else if (mimeType.includes('vp9')) { + selectedCodec = 'vp9' + } else if (mimeType.includes('vp8')) { + selectedCodec = 'vp8' + } break } } @@ -603,7 +845,12 @@ export function useWebRTC( return } - console.log('[WebRTC] 使用编码格式:', selectedMimeType) + console.log('[WebRTC] 使用编码格式:', selectedMimeType, '视频编解码器:', selectedCodec) + + // 如果不是 H.264,提示用户可能需要转码 + if (selectedCodec !== 'h264') { + console.warn('[WebRTC] ⚠️ 浏览器不支持 H.264 编码,使用', selectedCodec, '(需要服务器转码)') + } try { mediaRecorder = new MediaRecorder(localStream.value, { @@ -612,8 +859,23 @@ export function useWebRTC( audioBitsPerSecond: 64000, // 64kbps }) + // 首次发送编解码器信息给服务器 + let codecInfoSent = false + mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0 && wsPushSocket?.readyState === WebSocket.OPEN) { + // 首次发送时,先发送编解码器信息(JSON 格式) + if (!codecInfoSent) { + const codecInfo = JSON.stringify({ + type: 'codec_info', + video_codec: selectedCodec, + audio_codec: 'opus', + mime_type: selectedMimeType + }) + wsPushSocket.send(codecInfo) + codecInfoSent = true + console.log('[WebRTC] 已发送编解码器信息:', codecInfo) + } wsPushSocket.send(event.data) } } @@ -623,7 +885,7 @@ export function useWebRTC( } mediaRecorder.onstart = () => { - console.log('[WebRTC] MediaRecorder 开始录制') + console.log('[WebRTC] MediaRecorder 开始录制, 编解码器:', selectedCodec) } mediaRecorder.onstop = () => { @@ -691,24 +953,27 @@ export function useWebRTC( * @param streamIndex 流索引 */ function initFlvPlayer(videoElement: HTMLVideoElement, streamIndex: number = 0): flvjs.Player | null { + console.log('[WebRTC] 🎬 初始化 FLV 播放器, streamIndex:', streamIndex, '当前流列表:', call.miniprogramStreams) + if (!flvjs.isSupported()) { - console.error('[WebRTC] 浏览器不支持 flv.js') + console.error('[WebRTC] ❌ 浏览器不支持 flv.js') toastStore.error('您的浏览器不支持 FLV 播放') return null } const stream = call.miniprogramStreams[streamIndex] if (!stream || !stream.flvUrl) { - console.error('[WebRTC] 没有可用的 FLV 流') + console.error('[WebRTC] ❌ 没有可用的 FLV 流, stream:', stream) return null } // 如果已有播放器,先销毁 if (stream.player) { + console.log('[WebRTC] 🔄 销毁已有的 FLV 播放器') stream.player.destroy() } - console.log('[WebRTC] 初始化 FLV 播放器:', stream.flvUrl) + console.log('[WebRTC] 🎬 创建 FLV 播放器:', stream.flvUrl) const player = flvjs.createPlayer({ type: 'flv', @@ -728,10 +993,18 @@ export function useWebRTC( player.play() // 监听错误 - player.on(flvjs.Events.ERROR, (errorType, errorDetail) => { - console.error('[WebRTC] FLV 播放错误:', errorType, errorDetail) + player.on(flvjs.Events.ERROR, (errorType, errorDetail, errorInfo) => { + console.error('[WebRTC] FLV 播放错误:', errorType, errorDetail, errorInfo) if (errorType === flvjs.ErrorTypes.NETWORK_ERROR) { toastStore.error('网络错误,无法播放小程序视频流') + } else if (errorType === flvjs.ErrorTypes.MEDIA_ERROR) { + // 处理媒体错误,包括编解码器不支持 + if (errorDetail === 'CodecUnsupported') { + console.error('[WebRTC] 音视频编解码器不支持,小程序端需要配置 audio-codec="aac"') + toastStore.error('视频格式不兼容,请让小程序端更新后重试') + } else { + toastStore.error('视频播放失败: ' + errorDetail) + } } }) diff --git a/src/config/call.ts b/src/config/call.ts index 1cc123c..18bca18 100644 --- a/src/config/call.ts +++ b/src/config/call.ts @@ -1,12 +1,16 @@ /** * 通话模式配置 * - * 支持两种模式: + * 支持三种模式: * - webrtc: 原有 WebRTC P2P 模式,Web 与 Web 之间通话,低延迟 * - rtmp: RTMP 服务器中转模式,支持 Web 与小程序端互通 + * - auto: 自动模式,根据对方平台自动选择(Web-Web 用 WebRTC,Web-小程序 用 RTMP) */ -export type CallMode = 'webrtc' | 'rtmp' +export type CallMode = 'webrtc' | 'rtmp' | 'auto' + +// 平台类型 +export type Platform = 'h5' | 'app' | 'miniprogram' | 'unknown' /** * 获取当前通话模式 @@ -18,6 +22,7 @@ export function getCallMode(): CallMode { /** * 是否为 RTMP 模式 + * 注意:auto 模式下,此函数返回 false,需要使用 shouldUseRTMP 判断 */ export function isRTMPMode(): boolean { return getCallMode() === 'rtmp' @@ -25,11 +30,55 @@ export function isRTMPMode(): boolean { /** * 是否为 WebRTC 模式 + * 注意:auto 模式下,此函数返回 false,需要使用 shouldUseWebRTC 判断 */ export function isWebRTCMode(): boolean { return getCallMode() === 'webrtc' } +/** + * 是否为 auto 模式 + */ +export function isAutoMode(): boolean { + return getCallMode() === 'auto' +} + +/** + * 根据对方平台判断是否应该使用 RTMP 模式 + * - 如果当前是 rtmp 模式,始终返回 true + * - 如果当前是 webrtc 模式,始终返回 false + * - 如果当前是 auto 模式: + * - 对方是小程序 -> 使用 RTMP + * - 对方是 H5/App -> 使用 WebRTC + */ +export function shouldUseRTMP(remotePlatform?: Platform): boolean { + const mode = getCallMode() + + if (mode === 'rtmp') { + return true + } + + if (mode === 'webrtc') { + return false + } + + // auto 模式:根据对方平台决定 + if (remotePlatform === 'miniprogram') { + console.log('[CallConfig] Auto mode: 对方是小程序,使用 RTMP 模式') + return true + } + + console.log('[CallConfig] Auto mode: 对方是 Web/App,使用 WebRTC 模式') + return false +} + +/** + * 根据对方平台判断是否应该使用 WebRTC 模式 + */ +export function shouldUseWebRTC(remotePlatform?: Platform): boolean { + return !shouldUseRTMP(remotePlatform) +} + /** * 设置通话模式(运行时切换) */ @@ -37,3 +86,29 @@ export function setCallMode(mode: CallMode): void { (window as any).__CALL_MODE__ = mode } +/** + * 解析平台字符串 + * 统一不同端发送的平台标识: + * - 'web' / 'h5' -> 'h5' (Web 端) + * - 'wxapp' / 'miniprogram' / 'mini' / 'wechat' -> 'miniprogram' (小程序端) + * - 'app' -> 'app' (原生 App) + */ +export function parsePlatform(platform?: string): Platform { + if (!platform) return 'unknown' + + switch (platform.toLowerCase()) { + case 'h5': + case 'web': + return 'h5' + case 'app': + return 'app' + case 'miniprogram': + case 'mini': + case 'wechat': + case 'wxapp': // 新增:UniApp 小程序端发送的标识 + return 'miniprogram' + default: + return 'unknown' + } +} + diff --git a/src/main.ts b/src/main.ts index 12cb190..b654843 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,8 @@ import '@fortawesome/fontawesome-free/css/all.css' // ========== 通话模式配置 ========== // - 'webrtc': 原有 WebRTC 模式(Web 与 Web 之间 P2P 通话,低延迟) // - 'rtmp': RTMP 模式(支持与小程序端互通,通过服务器中转) -;(window as any).__CALL_MODE__ = 'rtmp' +// - 'auto': 自动模式(Web-Web 用 WebRTC,Web-小程序 用 RTMP) +;(window as any).__CALL_MODE__ = 'auto' const app = createApp(App) diff --git a/src/views/chat/ChatView.vue b/src/views/chat/ChatView.vue index a9b75d9..77597e7 100644 --- a/src/views/chat/ChatView.vue +++ b/src/views/chat/ChatView.vue @@ -2307,10 +2307,10 @@ watch(currentTab, (newTab) => { }) onMounted(async () => { - if (!authStore.isAuthenticated) { - const isValid = await authStore.checkAuth() - if (!isValid) { router.push('/login'); return } - } + // 每次页面加载/刷新时都验证 token 并获取最新用户信息 + const isValid = await authStore.checkAuth() + if (!isValid) { router.push('/login'); return } + if (authStore.user) { await wsManager.connect(authStore.user.id) diff --git a/vite.config.ts b/vite.config.ts index 06cb4e2..cd20b9a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,8 @@ export default defineConfig({ changeOrigin: true, }, '/upload': { - target: 'http://localhost:12080', + // target: 'http://localhost:12080', + target: 'https://g-ws.nailaoyun.cn', ws: true, changeOrigin: true, },