适配微信小程序

This commit is contained in:
2025-12-15 14:14:03 +08:00
parent 8e434a2b50
commit de7b9ae59f
3 changed files with 333 additions and 60 deletions

View File

@@ -36,7 +36,15 @@
</view> </view>
<!-- 本地推流 --> <!-- 本地推流 -->
<!-- autopush: URL 设置后自动推流组件会自动判断何时准备好 -->
<!--
关键配置说明
- video-codec: 优先使用硬件编码兼容性更好
- min-bitrate/max-bitrate: 设置合理的码率范围避免超出设备能力
- video-width/video-height: 明确指定分辨率避免自动选择不支持的分辨率
-->
<live-pusher <live-pusher
v-if="pushUrl"
id="local-pusher" id="local-pusher"
class="local-pusher" class="local-pusher"
:url="pushUrl" :url="pushUrl"
@@ -45,9 +53,18 @@
:enable-camera="!call.camOff" :enable-camera="!call.camOff"
:enable-mic="!call.muted" :enable-mic="!call.muted"
aspect="9:16" aspect="9:16"
beauty="5" :beauty="5"
whiteness="3" :whiteness="3"
orientation="vertical"
device-position="front"
video-codec="hardware"
:min-bitrate="200"
:max-bitrate="1000"
:video-width="360"
:video-height="640"
audio-quality="high"
@statechange="onPusherStateChange" @statechange="onPusherStateChange"
@netstatus="onPusherNetStatus"
@error="onPusherError" @error="onPusherError"
/> />
</view> </view>
@@ -175,24 +192,42 @@ const statusDotClass = computed(() => {
} }
}) })
// 监听通话激活,初始化 Context // 监听推流地址变化,在 live-pusher 渲染完成后初始化 Context
watch(() => call.active, (newVal) => { // 关键修复:之前监听 call.active但此时 pushUrl 还是空的live-pusher 未渲染
if (newVal) { // 现在改为监听 pushUrl确保 live-pusher 渲染后再初始化 Context
watch(() => pushUrl.value, (newVal, oldVal) => {
if (newVal && !oldVal) {
// pushUrl 从空变为有值live-pusher 即将渲染
console.log('[MiniProgramCall] pushUrl 已设置,准备初始化 Context')
nextTick(() => { nextTick(() => {
// 传入当前组件实例,确保 live-pusher 能被找到 // 等待 live-pusher 完全渲染后再初始化
// 在同层渲染模式下,这点尤为重要 setTimeout(() => {
initPusherContext(instance) console.log('[MiniProgramCall] 延迟后初始化 Pusher Context')
initPusherContext(instance)
}, 500)
}) })
} }
}) })
const onPusherError = (e: any) => { const onPusherError = (e: any) => {
console.error('Pusher Error:', e) console.error('[MiniProgramCall] Pusher Error:', e)
uni.showToast({ title: '推流失败,请检查摄像头权限', icon: 'none' }) const detail = e.detail || e
if (detail.errCode === 10001) {
uni.showToast({ title: '请授权摄像头和麦克风权限', icon: 'none' })
} else {
uni.showToast({ title: '推流失败,请检查权限设置', icon: 'none' })
}
}
const onPusherNetStatus = (e: any) => {
const info = e.detail?.info || e.detail
if (info) {
console.log('[MiniProgramCall] 推流网络状态:', info)
}
} }
const onPlayerError = (e: any, userId: string) => { const onPlayerError = (e: any, userId: string) => {
console.error(`Player Error [${userId}]:`, e) console.error(`[MiniProgramCall] Player Error [${userId}]:`, e)
} }
</script> </script>

View File

@@ -60,6 +60,10 @@ let currentReceiverUserId = ''
// live-pusher 组件上下文 // live-pusher 组件上下文
let pusherContext: UniApp.LivePusherContext | null = null let pusherContext: UniApp.LivePusherContext | null = null
// 推流状态标记
let isPushingSucceeded = false // autopush 是否已成功(收到 1009
let isStartingPush = false // 是否正在启动推流(防止并发调用)
export function useMiniProgramCall() { export function useMiniProgramCall() {
const authStore = useAuthStore() const authStore = useAuthStore()
const chatStore = useChatStore() const chatStore = useChatStore()
@@ -273,7 +277,7 @@ export function useMiniProgramCall() {
uni.showToast({ title: '服务器未返回推流地址', icon: 'none' }) uni.showToast({ title: '服务器未返回推流地址', icon: 'none' })
} }
// 设置拉流地址 // 设置拉流地址 (包括 Web/H5 用户的 RTMP 流)
if (response.pull_urls && response.pull_urls.length > 0) { if (response.pull_urls && response.pull_urls.length > 0) {
remoteStreams.value = response.pull_urls.map(p => ({ remoteStreams.value = response.pull_urls.map(p => ({
userId: p.user_id, userId: p.user_id,
@@ -281,6 +285,12 @@ export function useMiniProgramCall() {
flvUrl: p.flv_url, flvUrl: p.flv_url,
})) }))
console.log('[MiniProgramCall] 拉流地址:', remoteStreams.value) console.log('[MiniProgramCall] 拉流地址:', remoteStreams.value)
// 检测 Web 用户的流并提示
const webStreams = remoteStreams.value.filter(s => s.pullUrl && !s.pullUrl.includes('miniprogram'))
if (webStreams.length > 0) {
console.log('[MiniProgramCall] 检测到 Web 用户流:', webStreams.length)
}
} }
// 参与者信息 // 参与者信息
@@ -288,11 +298,41 @@ export function useMiniProgramCall() {
console.log('[MiniProgramCall] 房间参与者:', response.participants) console.log('[MiniProgramCall] 房间参与者:', response.participants)
} }
// 关键修复:使用 nextTick 确保 DOM 更新live-pusher 的 url 属性生效后再启动 // 重置推流状态标记
isPushingSucceeded = false
isStartingPush = false
// autopush=true 模式下live-pusher 会在 URL 设置后自动推流
// 时序说明:
// 1. pushUrl 设置后MiniProgramCallWindow.vue 中的 watch 会触发
// 2. watch 中会延迟 500ms 后调用 initPusherContext()
// 3. autopush 会自动开始推流
// 4. 当收到状态码 1009 (Send first video frame) 时,标记推流成功
// 5. 备用推流只在 autopush 未成功时触发
nextTick(() => { nextTick(() => {
checkAndLogPushStatus()
// 延迟后检查是否需要手动启动推流(仅作为 autopush 失败的备用方案)
setTimeout(() => { setTimeout(() => {
startPushing() // 关键检查:如果 autopush 已经成功,不再调用手动推流
}, 500) // 延迟500ms确保组件渲染完成 if (isPushingSucceeded) {
console.log('[MiniProgramCall] ✅ autopush 已成功,跳过备用推流')
return
}
if (call.active && pushUrl.value && pusherContext) {
console.log('[MiniProgramCall] 🔄 autopush 未成功,尝试手动启动推流')
startPushing()
} else if (call.active && pushUrl.value && !pusherContext) {
console.warn('[MiniProgramCall] ⚠️ pusherContext 未初始化,等待后重试')
setTimeout(() => {
if (!isPushingSucceeded && call.active && pushUrl.value && pusherContext) {
console.log('[MiniProgramCall] 🔄 重试手动启动推流')
startPushing()
}
}, 500)
}
}, 3000) // 延长到 3 秒,给 autopush 更多时间
}) })
// 开始计时 // 开始计时
@@ -383,64 +423,233 @@ export function useMiniProgramCall() {
pushUrl.value = '' pushUrl.value = ''
remoteStreams.value = [] remoteStreams.value = []
currentReceiverUserId = '' currentReceiverUserId = ''
// 重置推流状态标记
isPushingSucceeded = false
isStartingPush = false
} }
/** /**
* 开始推流 * 检查并请求摄像头和麦克风权限
*/ */
function startPushing() { async function checkAndRequestPermissions(): Promise<boolean> {
// #ifdef MP-WEIXIN
try {
// 检查摄像头权限
const cameraRes = await uni.getSetting({})
const cameraAuth = (cameraRes as any).authSetting?.['scope.camera']
const recordAuth = (cameraRes as any).authSetting?.['scope.record']
if (cameraAuth === false || recordAuth === false) {
// 权限被拒绝,引导用户到设置页
uni.showModal({
title: '权限提示',
content: '需要摄像头和麦克风权限才能进行视频通话,请在设置中开启',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
uni.openSetting({})
}
}
})
return false
}
if (cameraAuth === undefined) {
// 未授权,请求授权
await uni.authorize({ scope: 'scope.camera' })
}
if (recordAuth === undefined) {
await uni.authorize({ scope: 'scope.record' })
}
return true
} catch (err) {
console.error('[MiniProgramCall] 权限请求失败:', err)
return false
}
// #endif
return true
}
/**
* 检查推流状态并记录日志autopush 模式下使用)
*/
async function checkAndLogPushStatus() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
// 检测是否在开发者工具中运行
const systemInfo = uni.getSystemInfoSync() const systemInfo = uni.getSystemInfoSync()
const isDevtools = systemInfo.platform === 'devtools' const isDevtools = systemInfo.platform === 'devtools'
if (isDevtools) { if (isDevtools) {
console.warn('[MiniProgramCall] ⚠️ 开发者工具不支持 live-pusher,请在真机上测试') console.warn('[MiniProgramCall] ⚠️ 开发者工具不支持 live-pusher')
uni.showToast({ title: '开发者工具不支持推流,请真机测试', icon: 'none', duration: 3000 }) console.warn('[MiniProgramCall] 请使用手机扫码在「真机调试」或「预览」模式测试')
uni.showToast({
title: '开发者工具不支持视频通话,请真机测试',
icon: 'none',
duration: 4000
})
return
}
// 检查权限
const hasPermission = await checkAndRequestPermissions()
if (!hasPermission) {
console.error('[MiniProgramCall] ❌ 未获得必要权限')
uni.showToast({
title: '请授权摄像头和麦克风权限',
icon: 'none',
duration: 3000
})
return
}
console.log('[MiniProgramCall] 🎬 autopush 模式,推流地址已设置')
console.log('[MiniProgramCall] 推流地址:', pushUrl.value)
console.log('[MiniProgramCall] 设备信息:', {
platform: systemInfo.platform,
model: systemInfo.model,
system: systemInfo.system,
brand: systemInfo.brand,
SDKVersion: systemInfo.SDKVersion
})
// 检查基础库版本是否支持 live-pusher
const sdkVersion = systemInfo.SDKVersion || ''
const versionParts = sdkVersion.split('.').map(Number)
if (versionParts[0] < 2 || (versionParts[0] === 2 && versionParts[1] < 9)) {
console.warn('[MiniProgramCall] ⚠️ 基础库版本过低,建议升级到 2.9.0+')
uni.showToast({
title: '微信版本过低,请更新微信',
icon: 'none',
duration: 3000
})
}
// #endif
}
/**
* 手动开始推流
* 关键:在 RTC 模式下,需要先调用 enterRoom() 再调用 start()
* 参考网友方案enterRoom() 后调用 start(),设置 setTimeout 延迟
*/
async function startPushing() {
// #ifdef MP-WEIXIN
const systemInfo = uni.getSystemInfoSync()
const isDevtools = systemInfo.platform === 'devtools'
if (isDevtools) {
console.warn('[MiniProgramCall] ⚠️ 开发者工具不支持 live-pusher')
return
}
// 防止并发调用
if (isStartingPush) {
console.log('[MiniProgramCall] ⏳ 推流正在启动中,跳过重复调用')
return
}
// 如果已经推流成功,不再重复启动
if (isPushingSucceeded) {
console.log('[MiniProgramCall] ✅ 推流已成功,跳过重复启动')
return
} }
if (!pusherContext) { if (!pusherContext) {
console.error('[MiniProgramCall] ❌ Pusher Context 未初始化') console.error('[MiniProgramCall] ❌ Pusher Context 未初始化')
console.error('[MiniProgramCall] 请确保:')
console.error(' 1. 组件中有 <live-pusher id="local-pusher" ...>')
console.error(' 2. 调用了 initPusherContext(getCurrentInstance())')
uni.showToast({ title: 'live-pusher 组件未初始化', icon: 'none' })
return return
} }
// 确保 URL 存在
if (!pushUrl.value) { if (!pushUrl.value) {
console.error('[MiniProgramCall] ❌ 推流地址为空,无法推流') console.error('[MiniProgramCall] ❌ 推流地址为空')
return return
} }
console.log('[MiniProgramCall] 开始推流URL:', pushUrl.value) isStartingPush = true
console.log('[MiniProgramCall] 🎬 手动启动推流 - 使用 enterRoom + start 模式')
// 关键修复:先调用 enterRoom() 进入 RTC 房间
// 这是解决 operateXWebLivePusher:fail:internal error 的关键步骤
try {
// @ts-ignore - enterRoom 是较新的 API类型定义可能缺失
const ctx = pusherContext as any
if (typeof ctx.enterRoom === 'function') {
ctx.enterRoom({
success: () => {
console.log('[MiniProgramCall] ✅ enterRoom 成功')
// 延迟后调用 start(),确保房间准备就绪
setTimeout(() => {
doStartPushing()
}, 500)
},
fail: (err: any) => {
console.warn('[MiniProgramCall] ⚠️ enterRoom 失败,直接尝试 start:', err)
// enterRoom 失败时直接尝试 start
doStartPushing()
}
})
} else {
// 如果 enterRoom 不可用,直接调用 start
console.log('[MiniProgramCall] enterRoom 不可用,直接调用 start')
doStartPushing()
}
} catch (e) {
console.warn('[MiniProgramCall] enterRoom 调用异常,直接尝试 start:', e)
doStartPushing()
}
// #endif
}
/**
* 实际执行推流启动
*/
function doStartPushing() {
// #ifdef MP-WEIXIN
if (!pusherContext) {
isStartingPush = false
return
}
pusherContext.start({ pusherContext.start({
success: () => { success: () => {
console.log('[MiniProgramCall] ✅ 推流启动成功') console.log('[MiniProgramCall] ✅ 推流启动成功start回调')
isStartingPush = false
// 注意:真正的推流成功标志是收到状态码 1009
}, },
fail: (err: any) => { fail: (err: any) => {
console.error('[MiniProgramCall] ❌ 推流启动失败:', err) console.error('[MiniProgramCall] ❌ 推流启动失败:', err)
console.error('[MiniProgramCall] 错误信息:', JSON.stringify(err)) isStartingPush = false
// 详细的错误提示
let errorMsg = '推流失败' let errorMsg = '推流失败'
if (err.errMsg) { const errMsg = err.errMsg || ''
if (err.errMsg.includes('internal error')) { const errno = err.errno
errorMsg = '推流内部错误请检查RTMP服务器'
} else if (err.errMsg.includes('operateLivePusher:fail')) { if (errMsg.includes('internal error') || errno === 4) {
errorMsg = '推流操作失败' // internal error 通常是因为推流器已经在运行,或者状态冲突
} else if (err.errMsg.includes('permission')) { // 如果 autopush 已经在工作,这个错误可以忽略
errorMsg = '缺少摄像头/麦克风权限' if (isPushingSucceeded) {
console.log('[MiniProgramCall] internal error但 autopush 已成功,忽略')
return
} }
} errorMsg = '推流服务异常'
console.log('[MiniProgramCall] 🔄 internal error2秒后重试...')
uni.showToast({ title: errorMsg, icon: 'none', duration: 3000 }) setTimeout(() => {
if (!isPushingSucceeded && call.active && pusherContext) {
// 在开发者工具中,提示用户 isStartingPush = true
if (isDevtools) { pusherContext.start({
console.warn('[MiniProgramCall] 💡 提示:开发者工具不支持 live-pusher') success: () => {
console.warn('[MiniProgramCall] 请使用手机扫码在真机预览中测试') console.log('[MiniProgramCall] ✅ 重试推流成功')
isStartingPush = false
},
fail: (e: any) => {
console.error('[MiniProgramCall] ❌ 重试推流仍失败:', e)
isStartingPush = false
}
})
}
}, 2000)
} else if (errMsg.includes('permission') || errno === 10001) {
errorMsg = '请授权摄像头和麦克风权限'
uni.showToast({ title: errorMsg, icon: 'none', duration: 3000 })
} }
} }
}) })
@@ -605,20 +814,45 @@ export function useMiniProgramCall() {
break break
case 1004: // 自动调整分辨率 case 1004: // 自动调整分辨率
break break
case 1005: // 成功打开麦克风 case 1005: // 推流动态调整分辨率
break
case 1006: // 推流动态调整码率
break
case 1007: // 首帧画面采集完成
console.log('[MiniProgramCall] ✅ 首帧画面采集完成')
break
case 1008: // 编码器启动
console.log('[MiniProgramCall] ✅ 编码器启动')
break
case 1009: // 已发送首帧视频 - 这是推流真正成功的标志!
console.log('[MiniProgramCall] ✅✅ 推流成功!已发送首帧视频')
isPushingSucceeded = true // 关键:标记推流成功
isStartingPush = false
call.statusText = '通话中'
break
// 警告/网络状态
case 1101: // 网络状况不佳:上行带宽不足
console.warn('[MiniProgramCall] ⚠️ 网络不佳,上行带宽不足')
call.statusText = '网络不佳...'
break
case 1102: // 网络断连,已启动自动重连(不是编码器失败!)
console.log('[MiniProgramCall] 网络断连,正在自动重连...')
call.statusText = '重连中...'
// 重要1102 是自动重连,不需要手动干预!
// 微信会自动处理重连,我们只需要等待
break
case 1103: // 摄像头被占用
console.error('[MiniProgramCall] ❌ 摄像头被占用')
uni.showToast({ title: '摄像头被占用', icon: 'none' })
break
// 麦克风状态
case 2027: // 麦克风启动成功
console.log('[MiniProgramCall] ✅ 麦克风已打开') console.log('[MiniProgramCall] ✅ 麦克风已打开')
break break
// 警告状态 // RTMP 错误
case 1101: // 网络状况不佳:上行带宽不足
call.statusText = '网络不佳...'
break
case 1102: // 视频编码器启动失败
console.warn('[MiniProgramCall] ⚠️ 视频编码器启动失败')
break
case 1103: // 摄像头被占用
uni.showToast({ title: '摄像头被占用', icon: 'none' })
break
case 3001: // RTMP 服务器建立连接失败 case 3001: // RTMP 服务器建立连接失败
console.error('[MiniProgramCall] ❌ RTMP 服务器连接失败') console.error('[MiniProgramCall] ❌ RTMP 服务器连接失败')
call.statusText = '服务器连接失败' call.statusText = '服务器连接失败'
@@ -639,6 +873,8 @@ export function useMiniProgramCall() {
case 3005: // RTMP 服务器连接异常断开 case 3005: // RTMP 服务器连接异常断开
console.error('[MiniProgramCall] ❌ RTMP 连接异常断开') console.error('[MiniProgramCall] ❌ RTMP 连接异常断开')
call.statusText = '连接异常断开' call.statusText = '连接异常断开'
// 连接异常断开时,重置推流状态,允许重试
isPushingSucceeded = false
break break
// 错误状态 // 错误状态
@@ -659,6 +895,7 @@ export function useMiniProgramCall() {
case -1307: // 推流连接断开 case -1307: // 推流连接断开
console.error('[MiniProgramCall] ❌ 推流连接断开') console.error('[MiniProgramCall] ❌ 推流连接断开')
call.statusText = '连接已断开' call.statusText = '连接已断开'
isPushingSucceeded = false
break break
default: default:

View File

@@ -57,7 +57,7 @@
"urlCheck" : false, "urlCheck" : false,
"preloadBackgroundData" : false "preloadBackgroundData" : false
}, },
"libVersion" : "2.7.0", "libVersion" : "2.10.0",
"usingComponents" : true, "usingComponents" : true,
"darkmode" : true, "darkmode" : true,
"themeLocation" : "theme.json", "themeLocation" : "theme.json",
@@ -66,7 +66,7 @@
"desc" : "用于获取您的位置信息" "desc" : "用于获取您的位置信息"
}, },
"scope.record" : { "scope.record" : {
"desc" : "用于语音消息录制" "desc" : "用于语音消息录制和视频通话"
}, },
"scope.camera" : { "scope.camera" : {
"desc" : "用于拍摄照片和视频通话" "desc" : "用于拍摄照片和视频通话"
@@ -75,7 +75,8 @@
"desc" : "用于保存图片到相册" "desc" : "用于保存图片到相册"
} }
}, },
"requiredPrivateInfos" : [ "chooseImage", "chooseVideo", "chooseLocation", "getLocation" ] "requiredPrivateInfos" : [ "chooseLocation", "getLocation" ],
"requiredBackgroundModes" : [ "audio", "voip" ]
}, },
"mp-alipay" : { "mp-alipay" : {
"usingComponents" : true "usingComponents" : true