diff --git a/src/components/call/MiniProgramCallWindow.vue b/src/components/call/MiniProgramCallWindow.vue index 63fac86..2196beb 100644 --- a/src/components/call/MiniProgramCallWindow.vue +++ b/src/components/call/MiniProgramCallWindow.vue @@ -36,7 +36,15 @@ + + @@ -175,24 +192,42 @@ const statusDotClass = computed(() => { } }) -// 监听通话激活,初始化 Context -watch(() => call.active, (newVal) => { - if (newVal) { +// 监听推流地址变化,在 live-pusher 渲染完成后初始化 Context +// 关键修复:之前监听 call.active,但此时 pushUrl 还是空的,live-pusher 未渲染 +// 现在改为监听 pushUrl,确保 live-pusher 渲染后再初始化 Context +watch(() => pushUrl.value, (newVal, oldVal) => { + if (newVal && !oldVal) { + // pushUrl 从空变为有值,live-pusher 即将渲染 + console.log('[MiniProgramCall] pushUrl 已设置,准备初始化 Context') nextTick(() => { - // 传入当前组件实例,确保 live-pusher 能被找到 - // 在同层渲染模式下,这点尤为重要 - initPusherContext(instance) + // 等待 live-pusher 完全渲染后再初始化 + setTimeout(() => { + console.log('[MiniProgramCall] 延迟后初始化 Pusher Context') + initPusherContext(instance) + }, 500) }) } }) const onPusherError = (e: any) => { - console.error('Pusher Error:', e) - uni.showToast({ title: '推流失败,请检查摄像头权限', icon: 'none' }) + console.error('[MiniProgramCall] Pusher Error:', e) + 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) => { - console.error(`Player Error [${userId}]:`, e) + console.error(`[MiniProgramCall] Player Error [${userId}]:`, e) } diff --git a/src/composables/useMiniProgramCall.ts b/src/composables/useMiniProgramCall.ts index fd3745e..5756c6e 100644 --- a/src/composables/useMiniProgramCall.ts +++ b/src/composables/useMiniProgramCall.ts @@ -60,6 +60,10 @@ let currentReceiverUserId = '' // live-pusher 组件上下文 let pusherContext: UniApp.LivePusherContext | null = null +// 推流状态标记 +let isPushingSucceeded = false // autopush 是否已成功(收到 1009) +let isStartingPush = false // 是否正在启动推流(防止并发调用) + export function useMiniProgramCall() { const authStore = useAuthStore() const chatStore = useChatStore() @@ -273,7 +277,7 @@ export function useMiniProgramCall() { uni.showToast({ title: '服务器未返回推流地址', icon: 'none' }) } - // 设置拉流地址 + // 设置拉流地址 (包括 Web/H5 用户的 RTMP 流) if (response.pull_urls && response.pull_urls.length > 0) { remoteStreams.value = response.pull_urls.map(p => ({ userId: p.user_id, @@ -281,6 +285,12 @@ export function useMiniProgramCall() { flvUrl: p.flv_url, })) 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) } - // 关键修复:使用 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(() => { + checkAndLogPushStatus() + + // 延迟后检查是否需要手动启动推流(仅作为 autopush 失败的备用方案) setTimeout(() => { - startPushing() - }, 500) // 延迟500ms确保组件渲染完成 + // 关键检查:如果 autopush 已经成功,不再调用手动推流 + 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 = '' remoteStreams.value = [] currentReceiverUserId = '' + + // 重置推流状态标记 + isPushingSucceeded = false + isStartingPush = false } /** - * 开始推流 + * 检查并请求摄像头和麦克风权限 */ - function startPushing() { + async function checkAndRequestPermissions(): Promise { + // #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 - // 检测是否在开发者工具中运行 const systemInfo = uni.getSystemInfoSync() const isDevtools = systemInfo.platform === 'devtools' + if (isDevtools) { - console.warn('[MiniProgramCall] ⚠️ 开发者工具不支持 live-pusher,请在真机上测试') - uni.showToast({ title: '开发者工具不支持推流,请真机测试', icon: 'none', duration: 3000 }) + console.warn('[MiniProgramCall] ⚠️ 开发者工具不支持 live-pusher') + 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) { console.error('[MiniProgramCall] ❌ Pusher Context 未初始化') - console.error('[MiniProgramCall] 请确保:') - console.error(' 1. 组件中有 ') - console.error(' 2. 调用了 initPusherContext(getCurrentInstance())') - uni.showToast({ title: 'live-pusher 组件未初始化', icon: 'none' }) return } - // 确保 URL 存在 if (!pushUrl.value) { - console.error('[MiniProgramCall] ❌ 推流地址为空,无法推流') + console.error('[MiniProgramCall] ❌ 推流地址为空') 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({ success: () => { - console.log('[MiniProgramCall] ✅ 推流启动成功') + console.log('[MiniProgramCall] ✅ 推流启动成功(start回调)') + isStartingPush = false + // 注意:真正的推流成功标志是收到状态码 1009 }, fail: (err: any) => { console.error('[MiniProgramCall] ❌ 推流启动失败:', err) - console.error('[MiniProgramCall] 错误信息:', JSON.stringify(err)) - - // 详细的错误提示 + isStartingPush = false + let errorMsg = '推流失败' - if (err.errMsg) { - if (err.errMsg.includes('internal error')) { - errorMsg = '推流内部错误,请检查RTMP服务器' - } else if (err.errMsg.includes('operateLivePusher:fail')) { - errorMsg = '推流操作失败' - } else if (err.errMsg.includes('permission')) { - errorMsg = '缺少摄像头/麦克风权限' + const errMsg = err.errMsg || '' + const errno = err.errno + + if (errMsg.includes('internal error') || errno === 4) { + // internal error 通常是因为推流器已经在运行,或者状态冲突 + // 如果 autopush 已经在工作,这个错误可以忽略 + if (isPushingSucceeded) { + console.log('[MiniProgramCall] ℹ️ internal error,但 autopush 已成功,忽略') + return } - } - - uni.showToast({ title: errorMsg, icon: 'none', duration: 3000 }) - - // 在开发者工具中,提示用户 - if (isDevtools) { - console.warn('[MiniProgramCall] 💡 提示:开发者工具不支持 live-pusher') - console.warn('[MiniProgramCall] 请使用手机扫码在真机预览中测试') + errorMsg = '推流服务异常' + console.log('[MiniProgramCall] 🔄 internal error,2秒后重试...') + setTimeout(() => { + if (!isPushingSucceeded && call.active && pusherContext) { + isStartingPush = true + pusherContext.start({ + success: () => { + 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 case 1004: // 自动调整分辨率 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] ✅ 麦克风已打开') break - // 警告状态 - case 1101: // 网络状况不佳:上行带宽不足 - call.statusText = '网络不佳...' - break - case 1102: // 视频编码器启动失败 - console.warn('[MiniProgramCall] ⚠️ 视频编码器启动失败') - break - case 1103: // 摄像头被占用 - uni.showToast({ title: '摄像头被占用', icon: 'none' }) - break + // RTMP 错误 case 3001: // RTMP 服务器建立连接失败 console.error('[MiniProgramCall] ❌ RTMP 服务器连接失败') call.statusText = '服务器连接失败' @@ -639,6 +873,8 @@ export function useMiniProgramCall() { case 3005: // RTMP 服务器连接异常断开 console.error('[MiniProgramCall] ❌ RTMP 连接异常断开') call.statusText = '连接异常断开' + // 连接异常断开时,重置推流状态,允许重试 + isPushingSucceeded = false break // 错误状态 @@ -659,6 +895,7 @@ export function useMiniProgramCall() { case -1307: // 推流连接断开 console.error('[MiniProgramCall] ❌ 推流连接断开') call.statusText = '连接已断开' + isPushingSucceeded = false break default: diff --git a/src/manifest.json b/src/manifest.json index 3b765f3..42c7254 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -57,7 +57,7 @@ "urlCheck" : false, "preloadBackgroundData" : false }, - "libVersion" : "2.7.0", + "libVersion" : "2.10.0", "usingComponents" : true, "darkmode" : true, "themeLocation" : "theme.json", @@ -66,7 +66,7 @@ "desc" : "用于获取您的位置信息" }, "scope.record" : { - "desc" : "用于语音消息录制" + "desc" : "用于语音消息录制和视频通话" }, "scope.camera" : { "desc" : "用于拍摄照片和视频通话" @@ -75,7 +75,8 @@ "desc" : "用于保存图片到相册" } }, - "requiredPrivateInfos" : [ "chooseImage", "chooseVideo", "chooseLocation", "getLocation" ] + "requiredPrivateInfos" : [ "chooseLocation", "getLocation" ], + "requiredBackgroundModes" : [ "audio", "voip" ] }, "mp-alipay" : { "usingComponents" : true