bug修复
This commit is contained in:
@@ -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"
|
||||
|
||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
81
src/api/modules/call.ts
Normal file
81
src/api/modules/call.ts
Normal file
@@ -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<JoinCallRoomResponse>('/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}`)
|
||||
}
|
||||
|
||||
@@ -66,8 +66,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- B. 远程视频流 -->
|
||||
<!-- B. 远程视频流 (WebRTC) -->
|
||||
<video
|
||||
v-show="!hasFlvStream"
|
||||
ref="remoteVideoRef"
|
||||
class="w-full h-full transition-opacity duration-500 absolute inset-0 z-0 bg-black"
|
||||
:class="[
|
||||
@@ -78,6 +79,28 @@
|
||||
playsinline
|
||||
></video>
|
||||
|
||||
<!-- B2. 远程视频流 (FLV - 小程序流) -->
|
||||
<video
|
||||
v-show="hasFlvStream"
|
||||
ref="flvVideoRef"
|
||||
class="w-full h-full transition-opacity duration-500 absolute inset-0 z-0 bg-black"
|
||||
:class="[
|
||||
{ 'opacity-0': call.type === 'audio' || call.status !== 'connected' },
|
||||
call.minimized ? 'object-contain' : 'object-cover'
|
||||
]"
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
|
||||
<!-- 小程序流标识 -->
|
||||
<div
|
||||
v-if="hasFlvStream && call.status === 'connected' && !call.minimized"
|
||||
class="absolute top-20 left-4 z-30 bg-green-500/80 text-white text-xs px-2 py-1 rounded flex items-center gap-1"
|
||||
>
|
||||
<i class="fas fa-mobile-alt"></i>
|
||||
<span>小程序</span>
|
||||
</div>
|
||||
|
||||
<!-- C. 本地视频流 -->
|
||||
<div
|
||||
v-show="shouldShowLocalVideo"
|
||||
@@ -148,9 +171,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect, computed, onMounted, reactive, nextTick } from 'vue'
|
||||
import { ref, watchEffect, computed, onMounted, onUnmounted, reactive, nextTick, watch } from 'vue'
|
||||
import type flvjs from 'flv.js'
|
||||
import Avatar from '@/components/common/Avatar.vue' // 引入 Avatar
|
||||
import type { Contact } from '@/types/api'
|
||||
import type { FlvStreamInfo } from '@/composables/useWebRTC'
|
||||
|
||||
interface Props {
|
||||
call: {
|
||||
@@ -163,11 +188,15 @@ interface Props {
|
||||
camOff: boolean
|
||||
remoteCamOff: boolean
|
||||
duration: number
|
||||
miniprogramStreams?: FlvStreamInfo[]
|
||||
}
|
||||
target: Contact | null
|
||||
isMobile: boolean
|
||||
localStream: MediaStream | null
|
||||
remoteStream: MediaStream | null
|
||||
// FLV 播放相关(用于播放小程序流)
|
||||
initFlvPlayer?: (videoElement: HTMLVideoElement, streamIndex?: number) => flvjs.Player | null
|
||||
hasMiniprogramStreams?: () => boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -182,8 +211,15 @@ const emit = defineEmits<{
|
||||
|
||||
const localVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const flvVideoRef = ref<HTMLVideoElement | null>(null) // FLV 播放器视频元素
|
||||
const windowRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 是否有小程序流需要播放
|
||||
const hasFlvStream = computed(() => {
|
||||
return props.hasMiniprogramStreams?.() ||
|
||||
(props.call.miniprogramStreams && props.call.miniprogramStreams.length > 0)
|
||||
})
|
||||
|
||||
const shouldShowLocalVideo = computed(() => {
|
||||
return props.call.type === 'video' &&
|
||||
!props.call.minimized &&
|
||||
@@ -335,4 +371,43 @@ watchEffect(() => {
|
||||
videoEl.srcObject = null
|
||||
}
|
||||
})
|
||||
|
||||
// 监听小程序流,初始化 FLV 播放器
|
||||
let flvPlayer: flvjs.Player | null = null
|
||||
|
||||
watch(
|
||||
() => [props.call.miniprogramStreams, props.call.status, flvVideoRef.value],
|
||||
() => {
|
||||
// 当有小程序流且通话已连接时,初始化 FLV 播放器
|
||||
if (
|
||||
props.call.status === 'connected' &&
|
||||
props.call.miniprogramStreams &&
|
||||
props.call.miniprogramStreams.length > 0 &&
|
||||
flvVideoRef.value &&
|
||||
props.initFlvPlayer
|
||||
) {
|
||||
// 避免重复初始化
|
||||
if (!flvPlayer) {
|
||||
console.log('[CallWindow] 初始化 FLV 播放器')
|
||||
flvPlayer = props.initFlvPlayer(flvVideoRef.value, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 组件卸载时清理 FLV 播放器
|
||||
onUnmounted(() => {
|
||||
if (flvPlayer) {
|
||||
try {
|
||||
flvPlayer.pause()
|
||||
flvPlayer.unload()
|
||||
flvPlayer.detachMediaElement()
|
||||
flvPlayer.destroy()
|
||||
} catch (e) {
|
||||
console.error('[CallWindow] 销毁 FLV 播放器失败:', e)
|
||||
}
|
||||
flvPlayer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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<string>('')
|
||||
|
||||
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
|
||||
audioIncoming.loop = true
|
||||
|
||||
@@ -99,12 +117,20 @@ export function useWebRTC(
|
||||
call.status = 'connected'
|
||||
call.statusText = '通话中'
|
||||
startCallTimer()
|
||||
// 加入房间获取小程序用户的 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') {
|
||||
// WebRTC 模式才处理 offer
|
||||
if (!isRTMPMode()) {
|
||||
stopRingtone()
|
||||
if (!pc) {
|
||||
await initMedia(call.type === 'video')
|
||||
@@ -118,16 +144,21 @@ export function useWebRTC(
|
||||
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') {
|
||||
// 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')
|
||||
|
||||
// 根据模式选择不同的推流方式
|
||||
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')
|
||||
|
||||
// 根据模式选择不同的推流方式
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
39
src/config/call.ts
Normal file
39
src/config/call.ts
Normal file
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user