bug修复
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"@ant-design/icons-vue": "^7.0.0",
|
"@ant-design/icons-vue": "^7.0.0",
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"flv.js": "^1.6.2",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
|
|||||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.0
|
||||||
version: 1.13.2
|
version: 1.13.2
|
||||||
|
flv.js:
|
||||||
|
specifier: ^1.6.2
|
||||||
|
version: 1.6.2
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.3.1(typescript@5.3.3)(vue@3.5.25(typescript@5.3.3))
|
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==}
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es6-promise@4.2.8:
|
||||||
|
resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==}
|
||||||
|
|
||||||
esbuild@0.21.5:
|
esbuild@0.21.5:
|
||||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -705,6 +711,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
flv.js@1.6.2:
|
||||||
|
resolution: {integrity: sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==}
|
||||||
|
|
||||||
follow-redirects@1.15.11:
|
follow-redirects@1.15.11:
|
||||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -1172,6 +1181,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
webworkify-webpack@2.1.5:
|
||||||
|
resolution: {integrity: sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
@@ -1669,6 +1681,8 @@ snapshots:
|
|||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
|
|
||||||
|
es6-promise@4.2.8: {}
|
||||||
|
|
||||||
esbuild@0.21.5:
|
esbuild@0.21.5:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.21.5
|
'@esbuild/aix-ppc64': 0.21.5
|
||||||
@@ -1719,6 +1733,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
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: {}
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
form-data@4.0.5:
|
form-data@4.0.5:
|
||||||
@@ -2132,3 +2151,5 @@ snapshots:
|
|||||||
'@vue/shared': 3.5.25
|
'@vue/shared': 3.5.25
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.3.3
|
typescript: 5.3.3
|
||||||
|
|
||||||
|
webworkify-webpack@2.1.5: {}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
:is-mobile="isMobile"
|
:is-mobile="isMobile"
|
||||||
:local-stream="localStream"
|
:local-stream="localStream"
|
||||||
:remote-stream="remoteStream"
|
:remote-stream="remoteStream"
|
||||||
|
:init-flv-player="webrtcStore.webrtc?.initFlvPlayer"
|
||||||
|
:has-miniprogram-streams="webrtcStore.webrtc?.hasMiniprogramStreams"
|
||||||
@end-call="webrtcStore.webrtc.endCall"
|
@end-call="webrtcStore.webrtc.endCall"
|
||||||
@accept-call="webrtcStore.webrtc.acceptCall"
|
@accept-call="webrtcStore.webrtc.acceptCall"
|
||||||
@toggle-mute="webrtcStore.webrtc.toggleMute"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- B. 远程视频流 -->
|
<!-- B. 远程视频流 (WebRTC) -->
|
||||||
<video
|
<video
|
||||||
|
v-show="!hasFlvStream"
|
||||||
ref="remoteVideoRef"
|
ref="remoteVideoRef"
|
||||||
class="w-full h-full transition-opacity duration-500 absolute inset-0 z-0 bg-black"
|
class="w-full h-full transition-opacity duration-500 absolute inset-0 z-0 bg-black"
|
||||||
:class="[
|
:class="[
|
||||||
@@ -78,6 +79,28 @@
|
|||||||
playsinline
|
playsinline
|
||||||
></video>
|
></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. 本地视频流 -->
|
<!-- C. 本地视频流 -->
|
||||||
<div
|
<div
|
||||||
v-show="shouldShowLocalVideo"
|
v-show="shouldShowLocalVideo"
|
||||||
@@ -148,9 +171,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 Avatar from '@/components/common/Avatar.vue' // 引入 Avatar
|
||||||
import type { Contact } from '@/types/api'
|
import type { Contact } from '@/types/api'
|
||||||
|
import type { FlvStreamInfo } from '@/composables/useWebRTC'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
call: {
|
call: {
|
||||||
@@ -163,11 +188,15 @@ interface Props {
|
|||||||
camOff: boolean
|
camOff: boolean
|
||||||
remoteCamOff: boolean
|
remoteCamOff: boolean
|
||||||
duration: number
|
duration: number
|
||||||
|
miniprogramStreams?: FlvStreamInfo[]
|
||||||
}
|
}
|
||||||
target: Contact | null
|
target: Contact | null
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
localStream: MediaStream | null
|
localStream: MediaStream | null
|
||||||
remoteStream: MediaStream | null
|
remoteStream: MediaStream | null
|
||||||
|
// FLV 播放相关(用于播放小程序流)
|
||||||
|
initFlvPlayer?: (videoElement: HTMLVideoElement, streamIndex?: number) => flvjs.Player | null
|
||||||
|
hasMiniprogramStreams?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -182,8 +211,15 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const localVideoRef = ref<HTMLVideoElement | null>(null)
|
const localVideoRef = ref<HTMLVideoElement | null>(null)
|
||||||
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
|
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
|
||||||
|
const flvVideoRef = ref<HTMLVideoElement | null>(null) // FLV 播放器视频元素
|
||||||
const windowRef = ref<HTMLElement | null>(null)
|
const windowRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// 是否有小程序流需要播放
|
||||||
|
const hasFlvStream = computed(() => {
|
||||||
|
return props.hasMiniprogramStreams?.() ||
|
||||||
|
(props.call.miniprogramStreams && props.call.miniprogramStreams.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
const shouldShowLocalVideo = computed(() => {
|
const shouldShowLocalVideo = computed(() => {
|
||||||
return props.call.type === 'video' &&
|
return props.call.type === 'video' &&
|
||||||
!props.call.minimized &&
|
!props.call.minimized &&
|
||||||
@@ -335,4 +371,43 @@ watchEffect(() => {
|
|||||||
videoEl.srcObject = null
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { reactive, shallowRef, ref } from 'vue'
|
import { reactive, shallowRef, ref } from 'vue'
|
||||||
|
import flvjs from 'flv.js'
|
||||||
import * as messageApi from '@/api/modules/message'
|
import * as messageApi from '@/api/modules/message'
|
||||||
import * as systemApi from '@/api/modules/system'
|
import * as systemApi from '@/api/modules/system'
|
||||||
|
import * as callApi from '@/api/modules/call'
|
||||||
import { wsManager } from '@/api/websocket'
|
import { wsManager } from '@/api/websocket'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { isRTMPMode } from '@/config/call'
|
||||||
import type { ChatMessage, Contact, ICEServerConfig } from '@/types/api'
|
import type { ChatMessage, Contact, ICEServerConfig } from '@/types/api'
|
||||||
import type { CallStatus } from '@/types/message'
|
import type { CallStatus } from '@/types/message'
|
||||||
|
|
||||||
@@ -20,6 +23,15 @@ export interface CallState {
|
|||||||
remoteCamOff: boolean
|
remoteCamOff: boolean
|
||||||
duration: number
|
duration: number
|
||||||
startTime: number | null
|
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////////////////////////////////////////////////////////////////////////////////'
|
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,
|
remoteCamOff: false,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
startTime: null,
|
startTime: null,
|
||||||
|
miniprogramStreams: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const isCaller = ref(false)
|
const isCaller = ref(false)
|
||||||
@@ -56,6 +69,11 @@ export function useWebRTC(
|
|||||||
let currentReceiverUserId = ''
|
let currentReceiverUserId = ''
|
||||||
let currentRoomId = ''
|
let currentRoomId = ''
|
||||||
|
|
||||||
|
// RTMP 模式相关
|
||||||
|
let wsPushSocket: WebSocket | null = null
|
||||||
|
let mediaRecorder: MediaRecorder | null = null
|
||||||
|
const wsPushUrl = ref<string>('')
|
||||||
|
|
||||||
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
|
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
|
||||||
audioIncoming.loop = true
|
audioIncoming.loop = true
|
||||||
|
|
||||||
@@ -99,35 +117,48 @@ export function useWebRTC(
|
|||||||
call.status = 'connected'
|
call.status = 'connected'
|
||||||
call.statusText = '通话中'
|
call.statusText = '通话中'
|
||||||
startCallTimer()
|
startCallTimer()
|
||||||
if (!pc) await createPC()
|
// 加入房间获取小程序用户的 FLV 流地址(RTMP 模式下也会启动 WebSocket 推流)
|
||||||
const offer = await pc!.createOffer()
|
await joinCallRoomAndGetFlvUrls()
|
||||||
await pc!.setLocalDescription(offer)
|
|
||||||
sendSignal('offer', offer)
|
// WebRTC 模式:创建 Offer
|
||||||
|
if (!isRTMPMode()) {
|
||||||
|
if (!pc) await createPC()
|
||||||
|
const offer = await pc!.createOffer()
|
||||||
|
await pc!.setLocalDescription(offer)
|
||||||
|
sendSignal('offer', offer)
|
||||||
|
}
|
||||||
|
|
||||||
} else if (signal === 'offer') {
|
} else if (signal === 'offer') {
|
||||||
stopRingtone()
|
// WebRTC 模式才处理 offer
|
||||||
if (!pc) {
|
if (!isRTMPMode()) {
|
||||||
await initMedia(call.type === 'video')
|
stopRingtone()
|
||||||
await createPC()
|
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') {
|
} else if (signal === 'answer') {
|
||||||
if (pc) {
|
// WebRTC 模式才处理 answer
|
||||||
|
if (!isRTMPMode() && pc) {
|
||||||
await pc.setRemoteDescription(content)
|
await pc.setRemoteDescription(content)
|
||||||
processPendingCandidates()
|
processPendingCandidates()
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (signal === 'candidate') {
|
} else if (signal === 'candidate') {
|
||||||
if (pc && pc.remoteDescription) await pc.addIceCandidate(content)
|
// WebRTC 模式才处理 candidate
|
||||||
else pendingCandidates.push(content)
|
if (!isRTMPMode()) {
|
||||||
|
if (pc && pc.remoteDescription) await pc.addIceCandidate(content)
|
||||||
|
else pendingCandidates.push(content)
|
||||||
|
}
|
||||||
|
|
||||||
} else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) {
|
} else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) {
|
||||||
if (isCaller.value) {
|
if (isCaller.value) {
|
||||||
@@ -374,7 +405,16 @@ export function useWebRTC(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await initMedia(type === 'video')
|
await initMedia(type === 'video')
|
||||||
await createPC()
|
|
||||||
|
// 根据模式选择不同的推流方式
|
||||||
|
if (isRTMPMode()) {
|
||||||
|
console.log('[WebRTC] 使用 RTMP 模式')
|
||||||
|
// RTMP 模式:加入房间后会自动开始 WebSocket 推流
|
||||||
|
} else {
|
||||||
|
console.log('[WebRTC] 使用 WebRTC 模式')
|
||||||
|
await createPC()
|
||||||
|
}
|
||||||
|
|
||||||
playRingtone('dialing')
|
playRingtone('dialing')
|
||||||
sendSignal('invite', undefined, receiverUserId)
|
sendSignal('invite', undefined, receiverUserId)
|
||||||
return true
|
return true
|
||||||
@@ -396,7 +436,17 @@ export function useWebRTC(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await initMedia(call.type === 'video')
|
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)
|
sendSignal('accepted', undefined, currentReceiverUserId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
endCall()
|
endCall()
|
||||||
@@ -415,6 +465,8 @@ export function useWebRTC(
|
|||||||
}
|
}
|
||||||
// 被叫方不发送消息,由发起方统一发送
|
// 被叫方不发送消息,由发起方统一发送
|
||||||
sendSignal('hangup')
|
sendSignal('hangup')
|
||||||
|
// 离开通话房间
|
||||||
|
leaveCallRoom()
|
||||||
closeCall()
|
closeCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +478,10 @@ export function useWebRTC(
|
|||||||
call.statusText = ''
|
call.statusText = ''
|
||||||
call.id = null
|
call.id = null
|
||||||
stopCallTimer()
|
stopCallTimer()
|
||||||
|
|
||||||
|
// 停止 WebSocket 推流 (RTMP 模式)
|
||||||
|
stopWebSocketPush()
|
||||||
|
|
||||||
if (pc) {
|
if (pc) {
|
||||||
pc.close()
|
pc.close()
|
||||||
pc = null
|
pc = null
|
||||||
@@ -435,6 +491,280 @@ export function useWebRTC(
|
|||||||
localStream.value = null
|
localStream.value = null
|
||||||
}
|
}
|
||||||
remoteStream.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() {
|
async function processPendingCandidates() {
|
||||||
@@ -487,6 +817,10 @@ export function useWebRTC(
|
|||||||
handleSignaling,
|
handleSignaling,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
toggleCamera,
|
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'
|
import './assets/styles/main.scss'
|
||||||
// 引入 FontAwesome 主 CSS
|
// 引入 FontAwesome 主 CSS
|
||||||
import '@fortawesome/fontawesome-free/css/all.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)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
|
|||||||
Reference in New Issue
Block a user