diff --git a/package.json b/package.json index df7e6e4..1aa5d10 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@ant-design/icons-vue": "^7.0.0", "@fortawesome/fontawesome-free": "^7.1.0", "axios": "^1.6.0", + "flv.js": "^1.6.2", "pinia": "^2.1.0", "vue": "^3.4.0", "vue-router": "^4.2.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1da0da1..879fd10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: axios: specifier: ^1.6.0 version: 1.13.2 + flv.js: + specifier: ^1.6.2 + version: 1.6.2 pinia: specifier: ^2.1.0 version: 2.3.1(typescript@5.3.3)(vue@3.5.25(typescript@5.3.3)) @@ -673,6 +676,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -705,6 +711,9 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + flv.js@1.6.2: + resolution: {integrity: sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -1172,6 +1181,9 @@ packages: typescript: optional: true + webworkify-webpack@2.1.5: + resolution: {integrity: sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -1669,6 +1681,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es6-promise@4.2.8: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1719,6 +1733,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 + flv.js@1.6.2: + dependencies: + es6-promise: 4.2.8 + webworkify-webpack: 2.1.5 + follow-redirects@1.15.11: {} form-data@4.0.5: @@ -2132,3 +2151,5 @@ snapshots: '@vue/shared': 3.5.25 optionalDependencies: typescript: 5.3.3 + + webworkify-webpack@2.1.5: {} diff --git a/src/App.vue b/src/App.vue index 7bc6173..461be06 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,6 +11,8 @@ :is-mobile="isMobile" :local-stream="localStream" :remote-stream="remoteStream" + :init-flv-player="webrtcStore.webrtc?.initFlvPlayer" + :has-miniprogram-streams="webrtcStore.webrtc?.hasMiniprogramStreams" @end-call="webrtcStore.webrtc.endCall" @accept-call="webrtcStore.webrtc.acceptCall" @toggle-mute="webrtcStore.webrtc.toggleMute" diff --git a/src/api/modules/call.ts b/src/api/modules/call.ts new file mode 100644 index 0000000..487ab75 --- /dev/null +++ b/src/api/modules/call.ts @@ -0,0 +1,81 @@ +import request from '../request' + +/** + * 通话相关 API + */ + +// 拉流地址信息 +export interface PullURLInfo { + user_id: string + url: string + flv_url?: string +} + +// 参与者信息 +export interface ParticipantInfo { + user_id: string + platform: 'h5' | 'app' | 'miniprogram' + has_audio: boolean + has_video: boolean +} + +// ICE 服务器配置 +export interface ICEServerConfig { + urls: string[] + username?: string + credential?: string +} + +// 加入通话房间请求 +export interface JoinCallRoomRequest { + room_id: string + user_id: string + platform: 'h5' | 'app' | 'miniprogram' +} + +// 加入通话房间响应 +export interface JoinCallRoomResponse { + room_id: string + platform: string + ice_servers?: ICEServerConfig[] + ws_push_url?: string // H5/App 用 RTMP 模式 WebSocket 推流地址 + flv_pull_urls?: PullURLInfo[] // H5/App 拉取小程序流的 FLV 地址 + push_url?: string // 小程序用 RTMP 推流地址 + pull_urls?: PullURLInfo[] // 小程序用 RTMP 拉流地址 + participants: ParticipantInfo[] +} + +// 离开通话房间请求 +export interface LeaveCallRoomRequest { + room_id: string + user_id: string +} + +/** + * 加入通话房间 + * H5/App 加入时会返回房间内小程序用户的 FLV 拉流地址 + */ +export function joinCallRoom(data: JoinCallRoomRequest) { + return request.post('/call/join', data) +} + +/** + * 离开通话房间 + */ +export function leaveCallRoom(data: LeaveCallRoomRequest) { + return request.post<{ left: boolean }>('/call/leave', data) +} + +/** + * 获取通话房间信息 + */ +export function getCallRoom(roomId: string) { + return request.get<{ + room_id: string + call_type: string + is_group_call: boolean + participant_count: number + participants: ParticipantInfo[] + }>(`/call/room/${roomId}`) +} + diff --git a/src/components/call/CallWindow.vue b/src/components/call/CallWindow.vue index 4f0172c..7f7af07 100644 --- a/src/components/call/CallWindow.vue +++ b/src/components/call/CallWindow.vue @@ -66,8 +66,9 @@ - + + + +
+ + 小程序 +
+
diff --git a/src/composables/useWebRTC.ts b/src/composables/useWebRTC.ts index 41814b0..74ebbc2 100644 --- a/src/composables/useWebRTC.ts +++ b/src/composables/useWebRTC.ts @@ -1,9 +1,12 @@ import { reactive, shallowRef, ref } from 'vue' +import flvjs from 'flv.js' import * as messageApi from '@/api/modules/message' import * as systemApi from '@/api/modules/system' +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 type { ChatMessage, Contact, ICEServerConfig } from '@/types/api' import type { CallStatus } from '@/types/message' @@ -20,6 +23,15 @@ export interface CallState { remoteCamOff: boolean duration: number startTime: number | null + // 小程序流信息 + miniprogramStreams: FlvStreamInfo[] +} + +// FLV 流信息(来自小程序端) +export interface FlvStreamInfo { + userId: string + flvUrl: string + player?: flvjs.Player } const RINGTONE_INCOMING_BASE64 = 'data:audio/mp3;base64,//uQxAAAAAAAAAAAAEluZm8AAAAPAAAAHgAABOYADQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NAAAAAAJDTEFtZTMuMTAwBK8AAAAAAAAAABQJAAHIAAAAHgAABObK82LdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAAAAAAABAAAAAAAAAAASbXAzLm9yZwAAAP8AAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAEAMzI2hAAAAANIAAAAQAAAEAQGBAIAQBAEAQCBwAAAAe3/8w9///3v//+57//3///8w9///3v//+57//3///8AAKAAX/4EFwR//uQxBEAM8I2fAAAAANIAAAAQAAAEAAAAAAB///+BACAAAAAAH/9R//qD//6g////+oAAAD/4IHgAAAAAA//6gAAAAAT//uQxBIAOQI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxBsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxCsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxDQAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxE4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxE4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxF4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxGwAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxHgAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxIUAAANIAAAAAQAIAAAB//////////////////////////////////////////////////////////////////////////////////uQxIkAAANIAAAAAQAIAAAB////////////////////////////////////////////////////////////////////////////////' @@ -45,6 +57,7 @@ export function useWebRTC( remoteCamOff: false, duration: 0, startTime: null, + miniprogramStreams: [], }) const isCaller = ref(false) @@ -56,6 +69,11 @@ export function useWebRTC( let currentReceiverUserId = '' let currentRoomId = '' + // RTMP 模式相关 + let wsPushSocket: WebSocket | null = null + let mediaRecorder: MediaRecorder | null = null + const wsPushUrl = ref('') + const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64) audioIncoming.loop = true @@ -99,35 +117,48 @@ export function useWebRTC( call.status = 'connected' call.statusText = '通话中' startCallTimer() - if (!pc) await createPC() - const offer = await pc!.createOffer() - await pc!.setLocalDescription(offer) - sendSignal('offer', offer) + // 加入房间获取小程序用户的 FLV 流地址(RTMP 模式下也会启动 WebSocket 推流) + await joinCallRoomAndGetFlvUrls() + + // WebRTC 模式:创建 Offer + if (!isRTMPMode()) { + if (!pc) await createPC() + const offer = await pc!.createOffer() + await pc!.setLocalDescription(offer) + sendSignal('offer', offer) + } } else if (signal === 'offer') { - stopRingtone() - if (!pc) { - await initMedia(call.type === 'video') - await createPC() + // 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() } - 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') { - if (pc) { + // WebRTC 模式才处理 answer + if (!isRTMPMode() && pc) { await pc.setRemoteDescription(content) processPendingCandidates() } } else if (signal === 'candidate') { - if (pc && pc.remoteDescription) await pc.addIceCandidate(content) - else pendingCandidates.push(content) + // WebRTC 模式才处理 candidate + if (!isRTMPMode()) { + if (pc && pc.remoteDescription) await pc.addIceCandidate(content) + else pendingCandidates.push(content) + } } else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) { if (isCaller.value) { @@ -374,7 +405,16 @@ export function useWebRTC( try { await initMedia(type === 'video') - await createPC() + + // 根据模式选择不同的推流方式 + if (isRTMPMode()) { + console.log('[WebRTC] 使用 RTMP 模式') + // RTMP 模式:加入房间后会自动开始 WebSocket 推流 + } else { + console.log('[WebRTC] 使用 WebRTC 模式') + await createPC() + } + playRingtone('dialing') sendSignal('invite', undefined, receiverUserId) return true @@ -396,7 +436,17 @@ export function useWebRTC( try { await initMedia(call.type === 'video') - await createPC() + + // 根据模式选择不同的推流方式 + if (isRTMPMode()) { + console.log('[WebRTC] 接听:使用 RTMP 模式') + // RTMP 模式:加入房间后会自动开始 WebSocket 推流 + await joinCallRoomAndGetFlvUrls() + } else { + console.log('[WebRTC] 接听:使用 WebRTC 模式') + await createPC() + } + sendSignal('accepted', undefined, currentReceiverUserId) } catch (error) { endCall() @@ -415,6 +465,8 @@ export function useWebRTC( } // 被叫方不发送消息,由发起方统一发送 sendSignal('hangup') + // 离开通话房间 + leaveCallRoom() closeCall() } @@ -426,6 +478,10 @@ export function useWebRTC( call.statusText = '' call.id = null stopCallTimer() + + // 停止 WebSocket 推流 (RTMP 模式) + stopWebSocketPush() + if (pc) { pc.close() pc = null @@ -435,6 +491,280 @@ export function useWebRTC( localStream.value = null } remoteStream.value = null + // 清理 FLV 播放器 + destroyFlvPlayers() + call.miniprogramStreams = [] + } + + /** + * 加入通话房间并获取小程序用户的 FLV 流地址 + */ + async function joinCallRoomAndGetFlvUrls(): Promise { + if (!currentRoomId) return + + try { + const response = await callApi.joinCallRoom({ + room_id: currentRoomId, + user_id: userId, + platform: 'h5' + }) + + 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) + } + } + } catch (error) { + console.error('[WebRTC] 加入房间失败:', error) + } + } + + /** + * 开始 WebSocket 推流 (RTMP 模式) + * 使用 MediaRecorder 编码本地流,通过 WebSocket 发送到服务器 + */ + function startWebSocketPush(wsUrl: string): void { + if (!localStream.value) { + console.error('[WebRTC] 无法推流:本地流不存在') + return + } + + // 关闭已有连接 + stopWebSocketPush() + + console.log('[WebRTC] 开始 WebSocket 推流:', wsUrl) + + // 创建 WebSocket 连接 + wsPushSocket = new WebSocket(wsUrl) + + wsPushSocket.onopen = () => { + console.log('[WebRTC] WebSocket 连接已建立') + // 连接成功后开始录制 + startMediaRecorder() + } + + wsPushSocket.onclose = (event) => { + console.log('[WebRTC] WebSocket 连接已关闭:', event.code, event.reason) + } + + wsPushSocket.onerror = (error) => { + console.error('[WebRTC] WebSocket 错误:', error) + toastStore.error('推流连接失败') + } + + wsPushSocket.onmessage = (event) => { + // 处理服务器消息(如有需要) + console.log('[WebRTC] WebSocket 收到消息:', event.data) + } + } + + /** + * 开始 MediaRecorder 录制 + */ + function startMediaRecorder(): void { + if (!localStream.value || !wsPushSocket) { + return + } + + // 检查浏览器支持的 MIME 类型 + const mimeTypes = [ + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=h264,opus', + 'video/webm', + ] + + let selectedMimeType = '' + for (const mimeType of mimeTypes) { + if (MediaRecorder.isTypeSupported(mimeType)) { + selectedMimeType = mimeType + break + } + } + + if (!selectedMimeType) { + console.error('[WebRTC] 浏览器不支持 WebM 录制') + toastStore.error('您的浏览器不支持视频推流') + return + } + + console.log('[WebRTC] 使用编码格式:', selectedMimeType) + + try { + mediaRecorder = new MediaRecorder(localStream.value, { + mimeType: selectedMimeType, + videoBitsPerSecond: 500000, // 500kbps + audioBitsPerSecond: 64000, // 64kbps + }) + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0 && wsPushSocket?.readyState === WebSocket.OPEN) { + wsPushSocket.send(event.data) + } + } + + mediaRecorder.onerror = (error) => { + console.error('[WebRTC] MediaRecorder 错误:', error) + } + + mediaRecorder.onstart = () => { + console.log('[WebRTC] MediaRecorder 开始录制') + } + + mediaRecorder.onstop = () => { + console.log('[WebRTC] MediaRecorder 停止录制') + } + + // 每 100ms 发送一次数据 + mediaRecorder.start(100) + + } catch (error) { + console.error('[WebRTC] 创建 MediaRecorder 失败:', error) + toastStore.error('初始化推流失败') + } + } + + /** + * 停止 WebSocket 推流 + */ + function stopWebSocketPush(): void { + // 停止 MediaRecorder + if (mediaRecorder) { + try { + if (mediaRecorder.state !== 'inactive') { + mediaRecorder.stop() + } + } catch (e) { + // 忽略错误 + } + mediaRecorder = null + } + + // 关闭 WebSocket + if (wsPushSocket) { + try { + wsPushSocket.close() + } catch (e) { + // 忽略错误 + } + wsPushSocket = null + } + + wsPushUrl.value = '' + console.log('[WebRTC] WebSocket 推流已停止') + } + + /** + * 离开通话房间 + */ + async function leaveCallRoom(): Promise { + if (!currentRoomId) return + + try { + await callApi.leaveCallRoom({ + room_id: currentRoomId, + user_id: userId + }) + } catch (error) { + console.error('[WebRTC] 离开房间失败:', error) + } + } + + /** + * 初始化 FLV 播放器 + * @param videoElement 视频元素 + * @param streamIndex 流索引 + */ + function initFlvPlayer(videoElement: HTMLVideoElement, streamIndex: number = 0): flvjs.Player | null { + if (!flvjs.isSupported()) { + console.error('[WebRTC] 浏览器不支持 flv.js') + toastStore.error('您的浏览器不支持 FLV 播放') + return null + } + + const stream = call.miniprogramStreams[streamIndex] + if (!stream || !stream.flvUrl) { + console.error('[WebRTC] 没有可用的 FLV 流') + return null + } + + // 如果已有播放器,先销毁 + if (stream.player) { + stream.player.destroy() + } + + console.log('[WebRTC] 初始化 FLV 播放器:', stream.flvUrl) + + const player = flvjs.createPlayer({ + type: 'flv', + url: stream.flvUrl, + isLive: true, + hasAudio: true, + hasVideo: true, + }, { + enableWorker: false, + enableStashBuffer: false, + stashInitialSize: 128, + lazyLoad: false, + }) + + player.attachMediaElement(videoElement) + player.load() + player.play() + + // 监听错误 + player.on(flvjs.Events.ERROR, (errorType, errorDetail) => { + console.error('[WebRTC] FLV 播放错误:', errorType, errorDetail) + if (errorType === flvjs.ErrorTypes.NETWORK_ERROR) { + toastStore.error('网络错误,无法播放小程序视频流') + } + }) + + // 保存播放器引用 + stream.player = player + + return player + } + + /** + * 销毁所有 FLV 播放器 + */ + function destroyFlvPlayers(): void { + for (const stream of call.miniprogramStreams) { + if (stream.player) { + try { + stream.player.pause() + stream.player.unload() + stream.player.detachMediaElement() + stream.player.destroy() + } catch (e) { + console.error('[WebRTC] 销毁 FLV 播放器失败:', e) + } + stream.player = undefined + } + } + } + + /** + * 检查是否有小程序流需要播放 + */ + function hasMiniprogramStreams(): boolean { + return call.miniprogramStreams.length > 0 } async function processPendingCandidates() { @@ -487,6 +817,10 @@ export function useWebRTC( handleSignaling, toggleMute, toggleCamera, - formatDuration + formatDuration, + // FLV 播放相关(用于小程序流) + initFlvPlayer, + destroyFlvPlayers, + hasMiniprogramStreams, } } diff --git a/src/config/call.ts b/src/config/call.ts new file mode 100644 index 0000000..1cc123c --- /dev/null +++ b/src/config/call.ts @@ -0,0 +1,39 @@ +/** + * 通话模式配置 + * + * 支持两种模式: + * - webrtc: 原有 WebRTC P2P 模式,Web 与 Web 之间通话,低延迟 + * - rtmp: RTMP 服务器中转模式,支持 Web 与小程序端互通 + */ + +export type CallMode = 'webrtc' | 'rtmp' + +/** + * 获取当前通话模式 + * 从 window.__CALL_MODE__ 读取,默认为 'webrtc' + */ +export function getCallMode(): CallMode { + return (window as any).__CALL_MODE__ || 'webrtc' +} + +/** + * 是否为 RTMP 模式 + */ +export function isRTMPMode(): boolean { + return getCallMode() === 'rtmp' +} + +/** + * 是否为 WebRTC 模式 + */ +export function isWebRTCMode(): boolean { + return getCallMode() === 'webrtc' +} + +/** + * 设置通话模式(运行时切换) + */ +export function setCallMode(mode: CallMode): void { + (window as any).__CALL_MODE__ = mode +} + diff --git a/src/main.ts b/src/main.ts index 4251fce..12cb190 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,12 @@ import router from './router' import './assets/styles/main.scss' // 引入 FontAwesome 主 CSS import '@fortawesome/fontawesome-free/css/all.css' + +// ========== 通话模式配置 ========== +// - 'webrtc': 原有 WebRTC 模式(Web 与 Web 之间 P2P 通话,低延迟) +// - 'rtmp': RTMP 模式(支持与小程序端互通,通过服务器中转) +;(window as any).__CALL_MODE__ = 'rtmp' + const app = createApp(App) app.use(createPinia())