diff --git "a/IoT Video 345円260円217円347円250円213円345円272円217円P2P346円216円245円345円205円245円346円214円207円345円215円227円.md" "b/IoT Video 345円260円217円347円250円213円345円272円217円P2P346円216円245円345円205円245円346円214円207円345円215円227円.md" index e38254c..3d0e418 100644 --- "a/IoT Video 345円260円217円347円250円213円345円272円217円P2P346円216円245円345円205円245円346円214円207円345円215円227円.md" +++ "b/IoT Video 345円260円217円347円250円213円345円272円217円P2P346円216円245円345円205円245円346円214円207円345円215円227円.md" @@ -66,8 +66,12 @@ - [源码](./demo/miniprogram) - 体验二维码 + Demo 二维码 +
+
+> 该体验版二维码对应3.x.x版本的xp2p插件 #### Demo使用 注意:Demo UI交互可能更新,但主要流程不变 diff --git "a/changelog/3.0.0347円211円210円346円234円254円xp2p346円217円222円344円273円266円345円217円230円346円233円264円346円227円245円345円277円227円.md" "b/changelog/3.0.0347円211円210円346円234円254円xp2p346円217円222円344円273円266円345円217円230円346円233円264円346円227円245円345円277円227円.md" index 5704dc9..095d2e6 100644 --- "a/changelog/3.0.0347円211円210円346円234円254円xp2p346円217円222円344円273円266円345円217円230円346円233円264円346円227円245円345円277円227円.md" +++ "b/changelog/3.0.0347円211円210円346円234円254円xp2p346円217円222円344円273円266円345円217円230円346円233円264円346円227円245円345円277円227円.md" @@ -1,3 +1,48 @@ +# 3.0.1 xp2p插件变更日志 +- 修复设备断开连接后,未正确处理eof逻辑的问题 +```javascript +// 下载视频文件示例: +const file = { file_name: 'p2p_demo_file.mp4' }; +const params = `channel=0&file_name=${file.file_name}&offset=0`; + +// 临时文件路径 +const filePath = `${wx.env.USER_DATA_PATH}/${file.file_name.replace('/', '_')}`; + +// 使用FileSystemManager组装文件 +const fileSystemManager = wx.getFileSystemManager(); + +p2pModule.startLocalDownload(ipcId, { urlParams: params }, { + onChunkReceived: (chunk) => { + // 接收chunk包并组装文件 + fileSystemManager.appendFileSync(filePath, chunk, 'binary') + }, + onComplete: () => { + // 流结束了,会触发onComplete回调函数 + // 保存组装的临时视频文件到相册 + wx.saveVideoToPhotosAlbum({ + filePath, + success(res) { + // saved file handler + }, + fail(res) { + // Error handler + } + }); + }, + onSuccess: () => { + // 下载成功了,此时拿到的数据是完整的了 + }, + onFailure: (result) => { + // http.status>= 400 && http.status < 600 会触发这个事件 + // Error handler + }, + onError: (result) => { + // 网络传输错误,虽然http.status 为 200, 但下载过程中失败了。会触发这个事件 + }, +}); +``` +- 修复某些情况下使用udp6返回空的`data.local.address`时导致流程报错的问题 + # 3.0.0 xp2p插件变更日志 此次变更为打断性更新,不兼容之前的版本,即1.x.x版,2.x.x版 diff --git a/demo/miniprogram/app.json b/demo/miniprogram/app.json index 9dd17d6..df29795 100644 --- a/demo/miniprogram/app.json +++ b/demo/miniprogram/app.json @@ -8,23 +8,30 @@ "pages/test-p2p-player/player", "pages/test-p2p-pusher/pusher", "pages/test-live-player/test", - "pages/test-video/test" + "pages/test-video/test", + "pages/test-mjpg-canvas/test", + "pages/test-local-mjpg-player/player" ], "plugins": { "wechat-p2p-player": { - "version": "1.1.3", - "provider": "wx9e8fbc98ceac2628" + "version": "1.2.5", + "provider": "wx9e8fbc98ceac2628", + "export": "exportForPlugin.js" }, "xp2p": { - "version": "1.3.0", + "version": "3.1.2", "provider": "wx1319af22356934bf" } }, "usingComponents": { "p2p-player": "plugin://wechat-p2p-player/p2p-player", "p2p-pusher": "plugin://wechat-p2p-player/p2p-pusher", + "p2p-mjpg-player": "plugin://wechat-p2p-player/p2p-mjpg-player", + "mock-p2p-player": "components/mock-p2p-player/player", "iot-p2p-common-player": "components/iot-p2p-common-player/player", "iot-p2p-common-pusher": "components/iot-p2p-common-pusher/pusher", + "iot-p2p-mjpg-player": "components/iot-p2p-mjpg-player/player", + "iot-p2p-voice": "components/iot-p2p-voice/voice", "iot-p2p-player-ipc": "components/iot-p2p-player-ipc/player", "iot-p2p-player-server": "components/iot-p2p-player-server/player", "iot-p2p-control": "components/iot-p2p-control/control", diff --git a/demo/miniprogram/common.wxss b/demo/miniprogram/common.wxss index a730f76..b27f43d 100644 --- a/demo/miniprogram/common.wxss +++ b/demo/miniprogram/common.wxss @@ -12,10 +12,14 @@ .group-button-area { padding-top: 10rpx; } -.group-button { +.group-button-area .group-button { margin-right: 10rpx; } +.prop-item { + margin-bottom: 10rpx; +} + .text-box { box-sizing: border-box; padding: 0 10rpx; @@ -59,3 +63,7 @@ background-color: lightyellow; padding: 10rpx; } + +.hide { + display: none; +} diff --git a/demo/miniprogram/components/iot-p2p-common-player/common.js b/demo/miniprogram/components/iot-p2p-common-player/common.js new file mode 100644 index 0000000..a5ff918 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-common-player/common.js @@ -0,0 +1,103 @@ +// ts才能用enum,先这么处理吧 +export const PlayerStateEnum = { + PlayerIdle: 'PlayerIdle', + PlayerPreparing: 'PlayerPreparing', + PlayerReady: 'PlayerReady', + PlayerError: 'PlayerError', + LivePlayerError: 'LivePlayerError', + LivePlayerStateError: 'LivePlayerStateError', + LocalServerError: 'LocalServerError', +}; + +export const P2PStateEnum = { + P2PIdle: 'P2PIdle', + P2PUnkown: 'P2PUnkown', + P2PIniting: 'P2PIniting', + P2PInited: 'P2PInited', + P2PInitError: 'P2PInitError', + P2PLocalNATChanged: 'P2PLocalNATChanged', + ServicePreparing: 'ServicePreparing', + ServiceStarted: 'ServiceStarted', + ServiceStartError: 'ServiceStartError', + ServiceError: 'ServiceError', +}; + +export const StreamStateEnum = { + StreamIdle: 'StreamIdle', + StreamWaitPull: 'StreamWaitPull', + StreamReceivePull: 'StreamReceivePull', + StreamLocalServerError: 'StreamLocalServerError', + StreamChecking: 'StreamChecking', + StreamCheckSuccess: 'StreamCheckSuccess', + StreamCheckError: 'StreamCheckError', + StreamPreparing: 'StreamPreparing', + StreamStarted: 'StreamStarted', + StreamStartError: 'StreamStartError', + StreamRequest: 'StreamRequest', + StreamHeaderParsed: 'StreamHeaderParsed', + StreamHttpStatusError: 'StreamHttpStatusError', + StreamDataReceived: 'StreamDataReceived', + StreamDataPause: 'StreamDataPause', + StreamDataEnd: 'StreamDataEnd', + StreamError: 'StreamError', +}; + +export const totalMsgMap = { + [PlayerStateEnum.PlayerPreparing]: '正在创建播放器...', + [PlayerStateEnum.PlayerReady]: '创建播放器成功', + [PlayerStateEnum.PlayerError]: '播放器错误', + [PlayerStateEnum.LivePlayerError]: 'LivePlayer错误', + [PlayerStateEnum.LivePlayerStateError]: '播放失败', + [PlayerStateEnum.LocalServerError]: '本地HttpServer错误', + + [P2PStateEnum.P2PUnkown]: 'P2PUnkown', + [P2PStateEnum.P2PIniting]: '正在初始化p2p模块...', + [P2PStateEnum.P2PInited]: '初始化p2p模块完成', + [P2PStateEnum.P2PInitError]: '初始化p2p模块失败', + [P2PStateEnum.P2PLocalNATChanged]: '本地NAT发生变化', + [P2PStateEnum.ServicePreparing]: '正在启动p2p服务...', + [P2PStateEnum.ServiceStarted]: '启动p2p服务完成', + [P2PStateEnum.ServiceStartError]: '启动p2p服务失败', + [P2PStateEnum.ServiceError]: '连接失败或断开', + + [StreamStateEnum.StreamWaitPull]: '加载中...', + [StreamStateEnum.StreamReceivePull]: '加载中...', + [StreamStateEnum.StreamLocalServerError]: '本地HttpServer错误', + [StreamStateEnum.StreamChecking]: '加载中...', + [StreamStateEnum.StreamCheckSuccess]: '加载中...', + [StreamStateEnum.StreamCheckError]: '设备正忙,请稍后重试', + [StreamStateEnum.StreamPreparing]: '加载中...', + [StreamStateEnum.StreamStarted]: '加载中...', + [StreamStateEnum.StreamStartError]: '启动拉流失败', + [StreamStateEnum.StreamRequest]: '加载中...', + [StreamStateEnum.StreamHeaderParsed]: '加载中...', + [StreamStateEnum.StreamHttpStatusError]: '拉流失败', + [StreamStateEnum.StreamDataReceived]: '', + [StreamStateEnum.StreamDataPause]: '', + [StreamStateEnum.StreamDataEnd]: '播放中断或结束', + [StreamStateEnum.StreamError]: '播放失败', +}; + +export const httpStatusErrorMsgMap = { + 404: '拉流地址错误,请检查拉流参数', + 503: '连接数过多,请稍后再试', +}; + +export const isStreamPlaying = (streamState) => [ + StreamStateEnum.StreamPreparing, + StreamStateEnum.StreamStarted, + StreamStateEnum.StreamRequest, + StreamStateEnum.StreamHeaderParsed, + StreamStateEnum.StreamDataReceived, + StreamStateEnum.StreamDataPause, +].indexOf(streamState)>= 0; + +export const isStreamEnd = (streamState) => streamState === StreamStateEnum.StreamDataEnd; + +export const isStreamError = (streamState) => [ + StreamStateEnum.StreamLocalServerError, + StreamStateEnum.StreamCheckError, + StreamStateEnum.StreamStartError, + StreamStateEnum.StreamHttpStatusError, + StreamStateEnum.StreamError, +].indexOf(streamState)>= 0; diff --git a/demo/miniprogram/components/iot-p2p-common-player/player.js b/demo/miniprogram/components/iot-p2p-common-player/player.js index a6ffdda..8f19651 100644 --- a/demo/miniprogram/components/iot-p2p-common-player/player.js +++ b/demo/miniprogram/components/iot-p2p-common-player/player.js @@ -1,203 +1,44 @@ -import { canUseP2PIPCMode, canUseP2PServerMode, getParamValue } from '../../utils'; +/* eslint-disable camelcase, @typescript-eslint/naming-convention */ +import { canUseP2PIPCMode, canUseP2PServerMode, getParamValue, snapshotAndSave } from '../../utils'; import { getXp2pManager } from '../../lib/xp2pManager'; import { getRecordManager, MAX_FILE_SIZE_IN_M } from '../../lib/recordManager'; +import { PlayerStateEnum, P2PStateEnum, StreamStateEnum, totalMsgMap, httpStatusErrorMsgMap } from './common'; +import { PlayStepEnum, PlayStat } from './stat'; const xp2pManager = getXp2pManager(); -const { XP2PEventEnum, XP2PNotify_SubType } = xp2pManager; - -const recordManager = getRecordManager(); - -// ts才能用enum,先这么处理吧 -const PlayerStateEnum = { - PlayerIdle: 'PlayerIdle', - PlayerPreparing: 'PlayerPreparing', - PlayerReady: 'PlayerReady', - PlayerError: 'PlayerError', - LivePlayerError: 'LivePlayerError', - LivePlayerStateError: 'LivePlayerStateError', - LocalServerError: 'LocalServerError', -}; -const P2PStateEnum = { - P2PIdle: 'P2PIdle', - P2PUnkown: 'P2PUnkown', - P2PIniting: 'P2PIniting', - P2PInited: 'P2PInited', - P2PInitError: 'P2PInitError', - P2PLocalNATChanged: 'P2PLocalNATChanged', - ServicePreparing: 'ServicePreparing', - ServiceStarted: 'ServiceStarted', - ServiceStartError: 'ServiceStartError', - ServiceError: 'ServiceError', -}; -const StreamStateEnum = { - StreamIdle: 'StreamIdle', - StreamWaitPull: 'StreamWaitPull', - StreamReceivePull: 'StreamReceivePull', - StreamLocalServerError: 'StreamLocalServerError', - StreamChecking: 'StreamChecking', - StreamCheckSuccess: 'StreamCheckSuccess', - StreamCheckError: 'StreamCheckError', - StreamPreparing: 'StreamPreparing', - StreamStarted: 'StreamStarted', - StreamStartError: 'StreamStartError', - StreamRequest: 'StreamRequest', - StreamHeaderParsed: 'StreamHeaderParsed', - StreamDataReceived: 'StreamDataReceived', - StreamDataEnd: 'StreamDataEnd', - StreamError: 'StreamError', -}; - -const totalMsgMap = { - [PlayerStateEnum.PlayerPreparing]: '正在创建播放器...', - [PlayerStateEnum.PlayerReady]: '创建播放器成功', - [PlayerStateEnum.PlayerError]: '创建播放器失败', - [PlayerStateEnum.LivePlayerError]: 'LivePlayer错误', - [PlayerStateEnum.LivePlayerStateError]: '播放失败', - [PlayerStateEnum.LocalServerError]: '本地HttpServer错误', - - [P2PStateEnum.P2PUnkown]: 'P2PUnkown', - [P2PStateEnum.P2PIniting]: '正在初始化p2p模块...', - [P2PStateEnum.P2PInited]: '初始化p2p模块完成', - [P2PStateEnum.P2PInitError]: '初始化p2p模块失败', - [P2PStateEnum.P2PLocalNATChanged]: '本地NAT发生变化', - [P2PStateEnum.ServicePreparing]: '正在启动p2p服务...', - [P2PStateEnum.ServiceStarted]: '启动p2p服务完成', - [P2PStateEnum.ServiceStartError]: '启动p2p服务失败', - [P2PStateEnum.ServiceError]: '连接失败或断开', - - [StreamStateEnum.StreamWaitPull]: '加载中...', - [StreamStateEnum.StreamReceivePull]: '加载中...', - [StreamStateEnum.StreamLocalServerError]: '本地HttpServer错误', - [StreamStateEnum.StreamChecking]: '加载中...', - [StreamStateEnum.StreamCheckSuccess]: '加载中...', - [StreamStateEnum.StreamCheckError]: '设备正忙,请稍后重试', - [StreamStateEnum.StreamPreparing]: '加载中...', - [StreamStateEnum.StreamStarted]: '加载中...', - [StreamStateEnum.StreamStartError]: '启动拉流失败', - [StreamStateEnum.StreamRequest]: '加载中...', - [StreamStateEnum.StreamHeaderParsed]: '加载中...', - [StreamStateEnum.StreamDataReceived]: '', - [StreamStateEnum.StreamDataEnd]: '播放中断或结束', - [StreamStateEnum.StreamError]: '播放失败', -}; - -// 统计用 -// 启播步骤 -const PlayStepEnum = { - CreatePlayer: 'StepCreatePlayer', - InitModule: 'StepInitModule', - StartP2PService: 'StepStartP2PService', - // WaitBothReady: 'StepWaitBothReady', - // WaitTriggerPlay: 'StepWaitTriggerPlay', // 回放时等待选择录像的时间,不需要了,回放从选择录像开始计时 - ConnectLocalServer: 'StepConnectLocalServer', - WaitStream: 'StepWaitStream', - CheckStream: 'StepCheckStream', - StartStream: 'StepStartStream', - WaitHeader: 'StepWaitHeader', - WaitData: 'StepWaitData', - WaitIDR: 'StepWaitIDR', - AutoReconnect: 'StepAutoReconnect', // 没正常播放,liveplayer自动重连,2103 - FinalStop: 'StepFinalStop', // 多次重连抢救无效,-2301 -}; -const state2StepConfig = { - // player - [PlayerStateEnum.PlayerReady]: { - step: PlayStepEnum.CreatePlayer, - fromState: PlayerStateEnum.PlayerPreparing, - toState: PlayerStateEnum.PlayerReady, - }, - [PlayerStateEnum.PlayerError]: { - step: PlayStepEnum.CreatePlayer, - fromState: PlayerStateEnum.PlayerPreparing, - toState: PlayerStateEnum.PlayerError, - isResult: true, - }, +const { XP2PServiceEventEnum, XP2PEventEnum, XP2PNotify_SubType } = xp2pManager; - // p2p - [P2PStateEnum.P2PInited]: { - step: PlayStepEnum.InitModule, - fromState: P2PStateEnum.P2PIniting, - toState: P2PStateEnum.P2PInited, - }, - [P2PStateEnum.P2PInitError]: { - step: PlayStepEnum.InitModule, - fromState: P2PStateEnum.P2PIniting, - toState: P2PStateEnum.P2PInitError, - isResult: true, - }, - [P2PStateEnum.ServiceStarted]: { - step: PlayStepEnum.StartP2PService, - fromState: P2PStateEnum.ServicePreparing, - toState: P2PStateEnum.ServiceStarted, - }, - [P2PStateEnum.ServiceStartError]: { - step: PlayStepEnum.StartP2PService, - fromState: P2PStateEnum.ServicePreparing, - toState: P2PStateEnum.ServiceStartError, - isResult: true, - }, +const recordManager = getRecordManager('records'); - // stream - [StreamStateEnum.StreamReceivePull]: { - step: PlayStepEnum.ConnectLocalServer, - fromState: StreamStateEnum.StreamWaitPull, - toState: StreamStateEnum.StreamReceivePull, - }, - [StreamStateEnum.StreamLocalServerError]: { - step: PlayStepEnum.ConnectLocalServer, - fromState: StreamStateEnum.StreamWaitPull, - toState: StreamStateEnum.StreamLocalServerError, - }, - [StreamStateEnum.StreamCheckSuccess]: { - step: PlayStepEnum.CheckStream, - fromState: StreamStateEnum.StreamChecking, - toState: StreamStateEnum.StreamCheckSuccess, - }, - [StreamStateEnum.StreamCheckError]: { - step: PlayStepEnum.CheckStream, - fromState: StreamStateEnum.StreamChecking, - toState: StreamStateEnum.StreamCheckError, - isResult: true, - }, - [StreamStateEnum.StreamStarted]: { - step: PlayStepEnum.StartStream, - fromState: StreamStateEnum.StreamPreparing, - toState: StreamStateEnum.StreamStarted, - }, - [StreamStateEnum.StreamStartError]: { - step: PlayStepEnum.StartStream, - fromState: StreamStateEnum.StreamPreparing, - toState: StreamStateEnum.StreamStartError, - isResult: true, - }, - [StreamStateEnum.StreamHeaderParsed]: { - step: PlayStepEnum.WaitHeader, - fromState: StreamStateEnum.StreamStarted, - toState: StreamStateEnum.StreamHeaderParsed, - }, - [StreamStateEnum.StreamDataReceived]: { - step: PlayStepEnum.WaitData, - fromState: StreamStateEnum.StreamHeaderParsed, - toState: StreamStateEnum.StreamDataReceived, - isResult: true, - }, - [StreamStateEnum.StreamError]: { - step: PlayStepEnum.WaitStream, - fromState: StreamStateEnum.StreamStarted, - toState: StreamStateEnum.StreamError, - isResult: true, - }, -}; +const cacheIgnore = 500; let playerSeq = 0; Component({ behaviors: ['wx://component-export'], properties: { - mode: { + playerClass: { type: String, value: '', }, + // 以下是 live-player 的属性 + mode: { + type: String, // live / RTC + value: 'live', + }, + minCache: { + type: Number, + value: 0.2, + }, + maxCache: { + type: Number, + value: 0.8, + }, + // 以下是自己的属性 + p2pMode: { + type: String, // ipc / server + value: '', + }, targetId: { type: String, value: '', @@ -210,14 +51,25 @@ Component({ type: Boolean, value: false, }, - // 以下是 live-player 的属性 - muted: { + parseLivePlayerInfo: { type: Boolean, value: false, }, - orientation: { - type: String, - value: 'vertical', + cacheThreshold: { + type: Number, + value: 0, + }, + superMuted: { + type: Boolean, + value: false, + }, + showControlRightBtns: { + type: Boolean, + value: true, + }, + showLog: { + type: Boolean, + value: true, }, // 以下 ipc 模式用 productId: { @@ -232,12 +84,12 @@ Component({ type: String, value: '', }, - // 以下 server 模式用 - codeUrl: { + liveStreamDomain: { type: String, value: '', }, - liveStreamDomain: { + // 以下 server 模式用 + codeUrl: { type: String, value: '', }, @@ -266,7 +118,7 @@ Component({ // page相关 pageHideTimestamp: 0, - // 这是onLoad时就固定的 + // 这是attached时就固定的 streamExInfo: null, canUseP2P: false, needPlayer: false, @@ -275,30 +127,39 @@ Component({ flvFile: '', flvFilename: '', flvParams: '', + streamType: '', // player状态 hasPlayer: false, // needPlayer时才有效,出错销毁时设为false autoPlay: false, playerId: '', // 这是 p2p-player 组件的id,不是自己的id playerState: PlayerStateEnum.PlayerIdle, + playerDenied: false, playerComp: null, playerCtx: null, playerMsg: '', playerPaused: false, // false / true / 'stopped' needPauseStream: false, // 为true时不addChunk firstChunkDataInPaused: null, + acceptLivePlayerEvents: { + // 太多事件log了,只接收这3个 + error: true, + statechange: true, + netstatus: true, + // audiovolumenotify: true, + }, // p2p状态 currentP2PId: '', p2pState: P2PStateEnum.P2PIdle, p2pConnected: false, + checkStreamRes: null, // stream状态 streamState: StreamStateEnum.StreamIdle, playing: false, - totalBytes: 0, // 仅显示用,计算用 this.userData.totalBytes - // 这些是播放相关信息,清空时机同 totalBytes + // 这些是播放相关信息 livePlayerInfoStr: '', // debug用 @@ -306,23 +167,51 @@ Component({ isSlow: false, isRecording: false, - // 统计用 - playResultParams: null, + // 播放结果 playResultStr: '', - hasReceivedIDR: false, - idrResultParams: null, idrResultStr: '', + + // 控件 + muted: false, + orientation: 'vertical', + soundMode: 'speaker', + }, + observers: { + superMuted(val) { + this.console.info(`[${this.data.innerId}]`, 'superMuted changed', val); + if (val) { + // 打开pusher采集并关闭后,再打开recorder采集并关闭,player的soundMode就变成ear模式了,反馈给微信定位 + // 标记下次设为false时需要修复 soundMode + this.userData.needFixSoundMode = true; + } else { + if (this.userData.needFixSoundMode) { + // 已修复,清除标记 + this.userData.needFixSoundMode = false; + this.console.info(`[${this.data.innerId}]`, 'fix soundMode'); + wx.nextTick(() => { + this.setData({ + soundMode: 'ear', + }); + wx.nextTick(() => { + this.setData({ + soundMode: 'speaker', + }); + }); + }); + } + } + }, }, pageLifetimes: { show() { const hideTime = this.data.pageHideTimestamp ? Date.now() - this.data.pageHideTimestamp : 0; - console.log(`[${this.data.innerId}]`, '==== page show, hideTime', hideTime); + this.console.log(`[${this.data.innerId}]`, '==== page show, hideTime', hideTime); this.setData({ pageHideTimestamp: 0, }); }, hide() { - console.log(`[${this.data.innerId}]`, '==== page hide'); + this.console.log(`[${this.data.innerId}]`, '==== page hide'); this.setData({ pageHideTimestamp: Date.now(), }); @@ -333,7 +222,6 @@ Component({ // 在组件实例刚刚被创建时执行 playerSeq++; this.setData({ innerId: `common-player-${playerSeq}` }); - console.log(`[${this.data.innerId}]`, '==== created'); // 渲染无关,不放在data里,以免影响性能 this.userData = { @@ -342,16 +230,28 @@ Component({ totalBytes: 0, livePlayerInfo: null, fileObj: null, + needFixSoundMode: false, }; + + this.console = console; }, attached() { // 在组件实例进入页面节点树时执行 - console.log(`[${this.data.innerId}]`, '==== attached', this.id, this.properties); + if (!this.properties.showLog) { + this.console = { + log: () => undefined, + info: console.info, + warn: console.warn, + error: console.error, + }; + } + this.console.log(`[${this.data.innerId}]`, '==== attached', this.id, this.properties); - const isModeValid = this.properties.mode === 'ipc' || this.properties.mode === 'server'; - const canUseP2P = (this.properties.mode === 'ipc' && canUseP2PIPCMode) || (this.properties.mode === 'server' && canUseP2PServerMode); + const isP2PModeValid = this.properties.p2pMode === 'ipc' || this.properties.p2pMode === 'server'; + const canUseP2P = (this.properties.p2pMode === 'ipc' && canUseP2PIPCMode) || (this.properties.p2pMode === 'server' && canUseP2PServerMode); const flvFile = this.properties.flvUrl.split('/').pop(); const [flvFilename = '', flvParams = ''] = flvFile.split('?'); + const streamType = flvFilename ? (getParamValue(flvParams, 'action') || 'live') : ''; const onlyp2p = this.properties.onlyp2p || false; const needPlayer = !onlyp2p; const hasPlayer = needPlayer && canUseP2P; @@ -364,22 +264,61 @@ Component({ } else { p2pState = P2PStateEnum.P2PInitError; playerState = PlayerStateEnum.PlayerError; - playerMsg = isModeValid ? '您的微信基础库版本过低,请升级后再使用' : `无效的mode: ${this.properties.mode}`; + playerMsg = isP2PModeValid ? '您的微信基础库版本过低,请升级后再使用' : `无效的p2pType: ${this.properties.p2pMode}`; } + const { acceptLivePlayerEvents } = this.data; + acceptLivePlayerEvents.netstatus = this.properties.parseLivePlayerInfo; + // 统计用 + this.stat = new PlayStat({ + innerId: this.data.innerId, + console: this.console, + onPlayStepsChange: (params) => { + const playSteps = { + startAction: params.startAction, + timeCost: Date.now() - params.startTimestamp, + steps: params.steps.map((item, index) => `${index}: ${item.step} - ${item.timeCost}`), + }; + this.setData({ + playResultStr: JSON.stringify(playSteps, null, 2), + }); + }, + onPlayResultChange: (params) => { + // 把 totalBytes 显示出来 + let result = params.result; + if (this.userData.totalBytes) { + result += ` (${this.userData.totalBytes} bytes)`; + } + const playResult = { + result, + startAction: params.startAction, + timeCost: Date.now() - params.startTimestamp, + steps: params.steps.map((item, index) => `${index}: ${item.step} - ${item.timeCost}`), + }; + this.setData({ + playResultStr: JSON.stringify(playResult, null, 2), + }); + }, + onIdrResultChange: (idrResult) => { + this.setData({ + idrResultStr: `${PlayStepEnum.WaitIDR}: ${idrResult.timeCost} ms, ${this.userData.chunkCount} chunks, ${this.userData.totalBytes} bytes`, + }); + }, + }); this.makeResultParams({ startAction: 'enter', flvParams }); this.changeState({ flvFile, flvFilename, flvParams, + streamType, streamExInfo: { productId: this.properties.productId, deviceName: this.properties.deviceName, xp2pInfo: this.properties.xp2pInfo, - codeUrl: this.properties.codeUrl, liveStreamDomain: this.properties.liveStreamDomain, + codeUrl: this.properties.codeUrl, }, canUseP2P, needPlayer, @@ -388,6 +327,7 @@ Component({ playerState, playerMsg, p2pState, + acceptLivePlayerEvents, }); if (!canUseP2P) { @@ -401,9 +341,9 @@ Component({ }, detached() { // 在组件实例被从页面节点树移除时执行 - console.log(`[${this.data.innerId}]`, '==== detached'); + this.console.log(`[${this.data.innerId}]`, '==== detached'); this.stopAll(); - console.log(`[${this.data.innerId}]`, '==== detached end'); + this.console.log(`[${this.data.innerId}]`, '==== detached end'); }, error() { // 每当组件方法抛出错误时执行 @@ -413,12 +353,16 @@ Component({ return { changeFlv: this.changeFlv.bind(this), stopAll: this.stopAll.bind(this), + reset: this.reset.bind(this), + retry: this.onClickRetry.bind(this), pause: this.pause.bind(this), resume: this.resume.bind(this), resumeStream: this.resumeStream.bind(this), startRecording: this.startRecording.bind(this), stopRecording: this.stopRecording.bind(this), cancelRecording: this.cancelRecording.bind(this), + snapshot: this.snapshot.bind(this), + snapshotAndSave: this.snapshotAndSave.bind(this), }; }, methods: { @@ -453,9 +397,9 @@ Component({ }, // 包一层,方便更新 playerMsg changeState(newData, callback) { - this.addStateTimestamp(newData.playerState); - this.addStateTimestamp(newData.p2pState); - this.addStateTimestamp(newData.streamState); + this.stat.addStateTimestamp(newData.playerState); + this.stat.addStateTimestamp(newData.p2pState); + this.stat.addStateTimestamp(newData.streamState); const oldP2PState = this.data.p2pState; const oldStreamState = this.data.streamState; @@ -469,7 +413,7 @@ Component({ this.setData({ ...newData, ...playerDetail, - playerMsg: this.getPlayerMessage(newData), + playerMsg: typeof newData.playerMsg === 'string' ? newData.playerMsg : this.getPlayerMessage(newData), }, callback); if (newData.p2pState && newData.p2pState !== oldP2PState) { this.triggerEvent('p2pStateChange', { @@ -483,192 +427,29 @@ Component({ } }, makeResultParams({ startAction, flvParams }) { - const now = Date.now(); - const playResultParams = { - startAction, - flvParams: flvParams || this.data.flvParams, - timestamp: now, - lastTimestamp: now, - playTimestamps: {}, - steps: [], - firstChunkBytes: 0, - }; + this.stat.makeResultParams({ startAction, flvParams: flvParams || this.data.flvParams }); this.setData({ - playResultParams, playResultStr: '', - hasReceivedIDR: false, - idrResultParams: null, idrResultStr: '', }); - console.log(`[${this.data.innerId}]`, '==== start new play', startAction, flvParams); - }, - addStateTimestamp(state) { - if (!state || !this.data.playResultParams) { - return; - } - this.data.playResultParams.playTimestamps[state] = Date.now(); - const stepCfg = state2StepConfig[state]; - if (stepCfg) { - this.addStep(stepCfg.step, stepCfg); - } - }, - addStep(step, { fromState, toState, isResult } = {}) { - if (!step || !this.data.playResultParams) { - return; - } - const now = Date.now(); - const { playTimestamps } = this.data.playResultParams; - let fromTime = 0; - let toTime = 0; - if (fromState) { - if (!playTimestamps[fromState]) { - console.log(`[${this.data.innerId}]`, 'addStep', step, 'but no fromState', fromState); - return; - } - fromTime = playTimestamps[fromState]; - } else { - fromTime = this.data.playResultParams.lastTimestamp; - } - if (toState) { - if (!playTimestamps[toState]) { - console.log(`[${this.data.innerId}]`, 'addStep', step, 'but no toState', toState); - return; - } - toTime = playTimestamps[toState]; - } else { - toTime = now; - } - - const timeCost = toTime - fromTime; - console.log(`[${this.data.innerId}]`, 'addStep', step, timeCost, fromState ? `${fromState} -> ${toState || 'now'}` : ''); - this.data.playResultParams.lastTimestamp = now; - this.data.playResultParams.steps.push({ - step, - timeCost, - }); - - const { startAction, timestamp, firstChunkBytes, steps } = this.data.playResultParams; - const totalTimeCost = now - timestamp; - if (isResult) { - console.log(`[${this.data.innerId}]`, '==== play result', startAction, step, toState, totalTimeCost, this.data.playResultParams); - const byteStr = firstChunkBytes> 0 ? `(${firstChunkBytes} bytes)` : ''; - const playResultStr = JSON.stringify({ - result: `${toState} ${byteStr}`, - startAction, - timeCost: totalTimeCost, - steps: steps.map((item, index) => `${index}: ${item.step} - ${item.timeCost}`), - }, null, 2); - this.setData({ - playResultParams: null, - playResultStr, - }); - } else { - const playResultStr = JSON.stringify({ - startAction, - timeCost: totalTimeCost, - steps: steps.map((item, index) => `${index}: ${item.step} - ${item.timeCost}`), - }, null, 2); - this.setData({ - playResultStr, - }); - } }, createPlayer() { - console.log(`[${this.data.innerId}]`, 'createPlayer', Date.now()); + this.console.log(`[${this.data.innerId}]`, 'createPlayer', Date.now()); if (this.data.playerState !== PlayerStateEnum.PlayerIdle) { - console.error(`[${this.data.innerId}]`, 'can not createPlayer in playerState', this.data.playerState); + this.console.error(`[${this.data.innerId}]`, 'can not createPlayer in playerState', this.data.playerState); return; } this.changeState({ playerState: PlayerStateEnum.PlayerPreparing, }); - - if (!this.data.needPlayer) { - // mock 一个 - const playerExport = { - addChunk: () => {}, - }; - const livePlayerContext = { - isPlaying: false, // play/stop 用 - isPaused: false, // pause/resume 用 - play: ({ success, fail, complete } = {}) => { - if (livePlayerContext.isPlaying) { - fail && fail({ errMsg: 'already playing' }); - complete && complete(); - return; - } - livePlayerContext.isPlaying = true; - // livePlayerContext.isPaused = false; - setTimeout(() => { - success && success(); - complete && complete(); - !livePlayerContext.isPaused && this.onPlayerStartPull({}); - }, 0); - }, - stop: ({ success, fail, complete } = {}) => { - if (!livePlayerContext.isPlaying) { - fail && fail({ errMsg: 'not playing' }); - complete && complete(); - return; - } - livePlayerContext.isPlaying = false; - // livePlayerContext.isPaused = false; - // 这个是立刻调用的 - this.onPlayerClose({ detail: { error: { code: 'USER_CLOSE' } } }); - setTimeout(() => { - success && success(); - complete && complete(); - }, 0); - }, - pause: ({ success, fail, complete } = {}) => { - if (!livePlayerContext.isPlaying) { - fail && fail({ errMsg: 'not playing' }); - complete && complete(); - return; - } - if (livePlayerContext.isPaused) { - fail && fail({ errMsg: 'already paused' }); - complete && complete(); - return; - } - livePlayerContext.isPaused = true; - setTimeout(() => { - success && success(); - complete && complete(); - this.onPlayerClose({ detail: { error: { code: 'LIVE_PLAYER_CLOSED' } } }); - }, 0); - }, - resume: ({ success, fail, complete } = {}) => { - if (!livePlayerContext.isPlaying) { - fail && fail({ errMsg: 'not playing' }); - complete && complete(); - return; - } - if (!livePlayerContext.isPaused) { - fail && fail({ errMsg: 'not paused' }); - complete && complete(); - return; - } - livePlayerContext.isPaused = false; - setTimeout(() => { - success && success(); - complete && complete(); - this.onPlayerStartPull({}); - }, 0); - }, - }; - this.onPlayerReady({ - detail: { - playerExport, - livePlayerContext, - }, - }); - } }, onPlayerReady({ detail }) { - console.log(`[${this.data.innerId}]`, '==== onPlayerReady in', this.data.playerState, this.data.p2pState, detail); + this.console.log(`[${this.data.innerId}]`, '==== onPlayerReady in', this.data.playerState, this.data.p2pState, detail); const oldPlayerState = this.data.playerState; + if (oldPlayerState === PlayerStateEnum.PlayerReady) { + this.console.warn(`[${this.data.innerId}] onPlayerReady again, playerCtx ${detail.livePlayerContext === this.data.playerCtx ? 'same' : 'different'}`); + } this.changeState({ playerState: PlayerStateEnum.PlayerReady, playerComp: detail.playerExport, @@ -677,12 +458,12 @@ Component({ this.tryTriggerPlay(`${oldPlayerState} -> ${this.data.playerState}`); }, onPlayerStartPull({ detail }) { - console.log(`[${this.data.innerId}]`, `==== onPlayerStartPull in p2pState ${this.data.p2pState}, flvParams ${this.data.flvParams}, playerPaused ${this.data.playerPaused}, needPauseStream ${this.data.needPauseStream}`, detail); + this.console.log(`[${this.data.innerId}]`, `==== onPlayerStartPull in p2pState ${this.data.p2pState}, flvParams ${this.data.flvParams}, playerPaused ${this.data.playerPaused}, needPauseStream ${this.data.needPauseStream}`, detail); if (this.data.playerPaused && this.data.needPauseStream) { // ios暂停时不会断开连接,一段时间没收到数据就会触发startPull,但needPauseStream时不应该拉流 // 注意要把playerPaused改成特殊的 'stopped',否则resume会有问题,并且不能用 tryStopPlayer - console.warn(`[${this.data.innerId}]`, 'onPlayerStartPull but player paused and need pause stream, stop player'); + this.console.warn(`[${this.data.innerId}]`, 'onPlayerStartPull but player paused and need pause stream, stop player'); try { this.data.playerCtx.stop(params); } catch (err) {} @@ -693,15 +474,26 @@ Component({ } const checkIsFlvValid = this.properties.checkFunctions && this.properties.checkFunctions.checkIsFlvValid; - if (this.data.p2pState !== P2PStateEnum.ServiceStarted - || (checkIsFlvValid && !checkIsFlvValid({ filename: this.data.flvFilename, params: this.data.flvParams })) - ) { + if (checkIsFlvValid && !checkIsFlvValid({ filename: this.data.flvFilename, params: this.data.flvParams })) { + console.warn(`[${this.data.innerId}]`, 'flv invalid, return'); + return; + } + + if (this.data.p2pState !== P2PStateEnum.ServiceStarted) { // 因为各种各样的原因,player在状态不对的时候又触发播放了,停掉 - console.warn(`[${this.data.innerId}]`, 'onPlayerStartPull but can not play, stop player'); + this.console.warn(`[${this.data.innerId}]`, 'onPlayerStartPull but can not play, stop player'); this.tryStopPlayer(); return; } + if (this.data.streamState === StreamStateEnum.StreamDataPause) { + // 临时停止拉流,重新拉流时直接 doStartStream + const { livePlayerInfo } = this.userData; + this.console.warn(`[${this.data.innerId}] onPlayerStartPull in ${this.data.streamState}, now cache: video ${livePlayerInfo?.videoCache}, audio ${livePlayerInfo?.audioCache}`); + this.doStartStream(); + return; + } + // 收到pull this.changeState({ streamState: StreamStateEnum.StreamReceivePull, @@ -711,13 +503,28 @@ Component({ this.startStream(); }, onPlayerClose({ detail }) { - console.log(`[${this.data.innerId}]`, `==== onPlayerClose in p2pState ${this.data.p2pState}, playerPaused ${this.data.playerPaused}, needPauseStream ${this.data.needPauseStream}`, detail); + this.console.log(`[${this.data.innerId}]`, `==== onPlayerClose in p2pState ${this.data.p2pState}, playerPaused ${this.data.playerPaused}, needPauseStream ${this.data.needPauseStream}`, detail); + + let newStreamState = StreamStateEnum.StreamIdle; + const code = detail?.error?.code; + if (code === 'LIVE_PLAYER_CLOSED' && !this.data.playerPaused && !this.data.pageHideTimestamp) { + // live-player 断开请求,不是暂停也不是隐藏,可能是数据缓存太多播放器开始调控了 + const { livePlayerInfo } = this.userData; + const cache = Math.max(livePlayerInfo?.videoCache || 0, livePlayerInfo?.audioCache || 0); + if (cache> cacheIgnore) { + // 播放器还有cache + this.console.warn(`[${this.data.innerId}] onPlayerClose but player has cache: video ${livePlayerInfo?.videoCache}, audio ${livePlayerInfo?.audioCache}`, livePlayerInfo); + // 当作是临时停止拉流 + newStreamState = StreamStateEnum.StreamDataPause; + } + } + // 停止拉流 - this.stopStream(); + this.stopStream(newStreamState); }, onPlayerError({ detail }) { - console.log(`[${this.data.innerId}]`, '==== onPlayerError', detail); - const code = detail && detail.error && detail.error.code; + this.console.error(`[${this.data.innerId}]`, '==== onPlayerError', detail); + const code = detail?.error?.code; let playerState = PlayerStateEnum.PlayerError; if (code === 'WECHAT_SERVER_ERROR') { playerState = PlayerStateEnum.LocalServerError; @@ -735,19 +542,17 @@ Component({ } this.handlePlayError(playerState, { msg: `p2pPlayerError: ${code}` }); }, - // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars onLivePlayerError({ detail }) { - console.error(`[${this.data.innerId}]`, '==== onLivePlayerError', detail); - this.changeState({ + // 参考:https://developers.weixin.qq.com/miniprogram/dev/component/live-player.html + this.console.error(`[${this.data.innerId}]`, '==== onLivePlayerError', detail); + const newData = { playerState: PlayerStateEnum.LivePlayerError, - }); + }; if (detail.errMsg && detail.errMsg.indexOf('system permission denied')>= 0) { // 如果liveplayer是RTC模式,当微信没有系统录音权限时会出错,但是没有专用的错误码,微信侧建议先判断errMsg来兼容 - this.triggerEvent('systemPermissionDenied', detail); - return; + newData.playerDenied = true; } - // 其他错误,比如没有开通live-player组件权限 - // 参考:https://developers.weixin.qq.com/miniprogram/dev/component/live-player.html + this.changeState(newData); this.handlePlayError(PlayerStateEnum.LivePlayerError, { msg: `livePlayerError: ${detail.errMsg}` }); }, // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars @@ -771,21 +576,14 @@ Component({ break; case 2006: // 视频播放结束 case 6000: // 拉流被挂起 - console.log(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail, `streamState: ${this.data.streamState}`); + this.console.log(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail, `streamState: ${this.data.streamState}`); break; case 2003: // 网络接收到首个视频数据包(IDR) - console.log(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail, `totalBytes: ${this.data.totalBytes}`); - if (!this.data.hasReceivedIDR && this.data.idrResultParams) { - const timeCost = Date.now() - this.data.idrResultParams.playSuccTime; - this.data.idrResultParams.timeCost = timeCost; - this.setData({ - hasReceivedIDR: true, - idrResultStr: `${PlayStepEnum.WaitIDR}: ${timeCost} ms, ${this.userData.chunkCount} chunks, ${this.userData.totalBytes} bytes`, - }); - } + this.console.log(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail, `totalBytes: ${this.userData.totalBytes}`); + this.stat.receiveIDR(); break; case 2103: // live-player断连, 已启动自动重连 - console.log(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail, `streamState: ${this.data.streamState}`); + this.console.warn(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail, `streamState: ${this.data.streamState}`); if (/errCode:-1004(\D|$)/.test(detail.message) || /Failed to connect to/.test(detail.message)) { // 无法连接本地服务器 xp2pManager.needResetLocalServer = true; @@ -813,14 +611,14 @@ Component({ if (this.checkCanRetry()) { if (this.data.streamState !== StreamStateEnum.StreamIdle) { // 哪里有问题导致了重复发起请求,这应该是旧请求的消息,不处理了 - console.log(`[${this.data.innerId}]`, `livePlayer auto reconnect but streamState ${this.data.streamState}, ignore`); + this.console.log(`[${this.data.innerId}]`, `livePlayer auto reconnect but streamState ${this.data.streamState}, ignore`); return; } - this.addStep(PlayStepEnum.AutoReconnect); + this.stat.addStep(PlayStepEnum.AutoReconnect); // 前面收到playerStop的时候把streamState变成Idle了,这里再改成WaitPull - console.log(`[${this.data.innerId}]`, `livePlayer auto reconnect, ${this.data.streamState} -> ${StreamStateEnum.StreamWaitPull}`); + this.console.log(`[${this.data.innerId}]`, `livePlayer auto reconnect, ${this.data.streamState} -> ${StreamStateEnum.StreamWaitPull}`); this.changeState({ streamState: StreamStateEnum.StreamWaitPull, }); @@ -829,8 +627,8 @@ Component({ break; case -2301: // live-player断连,且经多次重连抢救无效,需要提示出错,由用户手动重试 // 到这里应该已经触发过 onPlayerClose 了 - console.log(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail); - this.addStep(PlayStepEnum.FinalStop, { isResult: true }); + this.console.error(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail); + this.stat.addStep(PlayStepEnum.FinalStop, { isResult: true }); this.changeState({ playerState: xp2pManager.needResetLocalServer ? PlayerStateEnum.LocalServerError @@ -843,13 +641,17 @@ Component({ break; default: // 这些不特别处理,打个log - console.log(`[${this.data.innerId}]`, 'onLivePlayerStateChange', detail.code, detail); + if ((detail.code>= 2104 && detail.code < 2200) || detail.code < 0) { + this.console.warn(`[${this.data.innerId}]`, 'onLivePlayerStateChange', detail.code, detail); + } else { + this.console.log(`[${this.data.innerId}]`, 'onLivePlayerStateChange', detail.code, detail); + } break; } }, // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - onLivePlayerNetStatusChange({ detail }) { - // console.log(`[${this.data.innerId}]`, 'onLivePlayerNetStatusChange', detail.info); + onLivePlayerNetStatus({ detail }) { + // this.console.log(`[${this.data.innerId}]`, 'onLivePlayerNetStatus', detail.info); if (!detail.info) { return; } @@ -865,17 +667,36 @@ Component({ } this.setData({ livePlayerInfoStr: [ - `size: ${detail.info.videoWidth}x${detail.info.videoHeight}, fps: ${detail.info.videoFPS?.toFixed(2)}`, - `bitrate(kbps): video ${detail.info.videoBitrate}, audio ${detail.info.audioBitrate}`, - `cache(ms): video ${detail.info.videoCache}, audio ${detail.info.audioCache}`, + `size: ${livePlayerInfo.videoWidth}x${livePlayerInfo.videoHeight}, fps: ${livePlayerInfo.videoFPS?.toFixed(2)}`, + `bitrate(kbps): video ${livePlayerInfo.videoBitrate}, audio ${livePlayerInfo.audioBitrate}`, + `cache(ms): video ${livePlayerInfo.videoCache}, audio ${livePlayerInfo.audioCache}`, ].join('\n'), }); + + const cache = Math.max(livePlayerInfo?.videoCache || 0, livePlayerInfo?.audioCache || 0); + if (this.properties.cacheThreshold> 0 && cache> this.properties.cacheThreshold) { + // cache太多了,停止播放 + this.stopStream(); + this.tryStopPlayer({ + success: () => { + if (!this.properties.autoReplay) { + return; + } + this.changeState({ + streamState: StreamStateEnum.StreamWaitPull, + }); + this.console.log(`[${this.data.innerId}]`, 'trigger replay'); + this.data.playerCtx.play(); + }, + }); + } }, resetServiceData(newP2PState) { this.changeState({ currentP2PId: '', p2pState: newP2PState, p2pConnected: false, + checkStreamRes: null, }); }, resetStreamData(newStreamState) { @@ -887,10 +708,6 @@ Component({ }); }, clearStreamData() { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = null; - } if (this.userData) { this.userData.chunkTime = 0; this.userData.chunkCount = 0; @@ -899,14 +716,13 @@ Component({ } this.setData({ firstChunkDataInPaused: null, - totalBytes: 0, livePlayerInfoStr: '', }); }, initP2P() { - console.log(`[${this.data.innerId}]`, 'initP2P'); + this.console.log(`[${this.data.innerId}]`, 'initP2P'); if (this.data.p2pState !== P2PStateEnum.P2PIdle) { - console.log(`[${this.data.innerId}]`, 'can not initP2P in p2pState', this.data.p2pState); + this.console.log(`[${this.data.innerId}]`, 'can not initP2P in p2pState', this.data.p2pState); return; } @@ -922,27 +738,19 @@ Component({ return; } - console.log(`[${this.data.innerId}]`, 'initModule'); + this.console.log(`[${this.data.innerId}]`, 'initModule'); xp2pManager .initModule() .then((res) => { - console.log(`[${this.data.innerId}]`, '==== initModule res', res, 'in p2pState', this.data.p2pState); - if (res === 0) { - this.changeState({ - p2pState: P2PStateEnum.P2PInited, - }); - this.prepare(); - } else { - xp2pManager.destroyModule(); - this.changeState({ - p2pState: P2PStateEnum.P2PInitError, - }); - this.handlePlayError(P2PStateEnum.P2PInitError, { msg: `initModule res ${res}` }); - } + this.console.log(`[${this.data.innerId}]`, '==== initModule res', res, 'in p2pState', this.data.p2pState); + this.changeState({ + p2pState: P2PStateEnum.P2PInited, + }); + this.prepare(); }) .catch((errcode) => { - console.error(`[${this.data.innerId}]`, '==== initModule error', errcode, 'in p2pState', this.data.p2pState); + this.console.error(`[${this.data.innerId}]`, '==== initModule error', errcode, 'in p2pState', this.data.p2pState); xp2pManager.destroyModule(); this.changeState({ p2pState: P2PStateEnum.P2PInitError, @@ -951,21 +759,26 @@ Component({ }); }, prepare() { - console.log(`[${this.data.innerId}]`, 'prepare'); + this.console.log(`[${this.data.innerId}]`, 'prepare'); if (this.data.p2pState !== P2PStateEnum.P2PInited && this.data.p2pState !== P2PStateEnum.ServiceStartError && this.data.p2pState !== P2PStateEnum.ServiceError ) { - console.log(`can not start service in p2pState ${this.data.p2pState}`); + this.console.log(`can not start service in p2pState ${this.data.p2pState}`); return; } const { targetId } = this.properties; const { flvUrl, streamExInfo } = this.data; - console.log(`[${this.data.innerId}]`, 'startP2PService', targetId, flvUrl, streamExInfo); + if (!targetId || !flvUrl) { + this.console.log(`can not start service with invalid params: targetId ${targetId}, flvUrl ${flvUrl}`); + return; + } + + this.console.log(`[${this.data.innerId}]`, 'startP2PService', targetId, flvUrl, streamExInfo); const msgCallback = (event, subtype, detail) => { - this.onP2PMessage(targetId, event, subtype, detail); + this.onP2PServiceMessage(targetId, event, subtype, detail); }; this.changeState({ @@ -973,18 +786,16 @@ Component({ p2pState: P2PStateEnum.ServicePreparing, }); - console.log('=-=------------------------------'); xp2pManager .startP2PService( targetId, { url: flvUrl, ...streamExInfo }, { msgCallback, - // 不传 dataCallback 表示后面再启动拉流 }, ) .then((res) => { - console.log(`[${this.data.innerId}]`, '==== startP2PService res', res); + this.console.log(`[${this.data.innerId}]`, '==== startP2PService res', res); if (res === 0) { const oldP2PState = this.data.p2pState; this.changeState({ @@ -997,28 +808,28 @@ Component({ } }) .catch((errcode) => { - console.error(`[${this.data.innerId}]`, '==== startP2PService error', errcode); + this.console.error(`[${this.data.innerId}]`, '==== startP2PService error', errcode); this.stopAll(P2PStateEnum.ServiceStartError); this.handlePlayError(P2PStateEnum.ServiceStartError, { msg: `startP2PService err ${errcode}` }); }); }, startStream() { - console.log(`[${this.data.innerId}]`, 'startStream'); + this.console.log(`[${this.data.innerId}]`, 'startStream'); if (this.data.p2pState !== P2PStateEnum.ServiceStarted) { - console.log(`[${this.data.innerId}]`, `can not start stream in p2pState ${this.data.p2pState}`); + this.console.log(`[${this.data.innerId}]`, `can not start stream in p2pState ${this.data.p2pState}`); return; } if (this.data.playing) { - console.log(`[${this.data.innerId}]`, 'already playing'); + this.console.log(`[${this.data.innerId}]`, 'already playing'); return; } // 先检查能否拉流 const checkCanStartStream = this.properties.checkFunctions && this.properties.checkFunctions.checkCanStartStream; - console.log(`[${this.data.innerId}]`, 'need checkCanStartStream', !!checkCanStartStream); + this.console.log(`[${this.data.innerId}]`, 'need checkCanStartStream', !!checkCanStartStream, 'checkStreamRes', this.data.checkStreamRes); - if (!checkCanStartStream) { - // 不检查,直接拉流 + if (!checkCanStartStream || this.data.checkStreamRes) { + // 不检查或已经检查成功,直接拉流 this.doStartStream(); return; } @@ -1036,9 +847,10 @@ Component({ return; } // 检查通过,开始拉流 - console.log(`[${this.data.innerId}]`, '==== checkCanStartStream success'); + this.console.log(`[${this.data.innerId}]`, '==== checkCanStartStream success'); this.changeState({ streamState: StreamStateEnum.StreamCheckSuccess, + checkStreamRes: true, }); this.doStartStream(); }) @@ -1048,8 +860,9 @@ Component({ return; } // 检查失败,前面已经弹过提示了 - console.log(`[${this.data.innerId}]`, '==== checkCanStartStream error', errmsg); + this.console.error(`[${this.data.innerId}]`, '==== checkCanStartStream error', errmsg); this.resetStreamData(StreamStateEnum.StreamCheckError); + this.setData({ checkStreamRes: false }); if (errmsg) { this.setData({ playerMsg: errmsg }); } @@ -1057,7 +870,12 @@ Component({ }); }, doStartStream() { - console.log(`[${this.data.innerId}]`, 'do startStream', this.properties.targetId, this.data.flvFilename, this.data.flvParams); + this.console.log(`[${this.data.innerId}]`, 'do startStream', this.properties.targetId, this.data.flvFilename, this.data.flvParams); + + const { targetId } = this.properties; + const msgCallback = (event, subtype, detail) => { + this.onP2PMessage(targetId, event, subtype, detail, { isStream: true }); + }; const { playerComp } = this.data; let chunkTime = 0; @@ -1076,7 +894,7 @@ Component({ if (this.data.needPauseStream) { // 要暂停流,不发数据给player,但是header要记下来后面发。。。 if (!chunkCount && !this.data.firstChunkDataInPaused) { - console.log(`[${this.data.innerId}]`, '==== firstChunkDataInPaused', data.byteLength); + this.console.log(`[${this.data.innerId}]`, '==== firstChunkDataInPaused', data.byteLength); this.setData({ firstChunkDataInPaused: data, }); @@ -1093,34 +911,21 @@ Component({ this.userData.totalBytes = totalBytes; } if (chunkCount === 1) { - console.log(`[${this.data.innerId}]`, '==== firstChunk', data.byteLength); - if (this.data.playResultParams) { - this.data.playResultParams.firstChunkBytes = data.byteLength; - this.setData({ - hasReceivedIDR: false, - idrResultParams: { - playSuccTime: Date.now(), - }, - idrResultStr: '', - }); - } + this.console.log(`[${this.data.innerId}]`, '==== firstChunk', data.byteLength); this.changeState({ streamState: StreamStateEnum.StreamDataReceived, }); - this.setData({ - totalBytes, // 第一个立刻刷新 - }); } - // 控制刷新频率 - this.refreshBytesDelay(); + if (this.userData?.fileObj) { // 写录像文件 const writeLen = recordManager.writeRecordFile(this.userData.fileObj, data); if (writeLen < 0) { // 写入失败,可能是超过限制了 - stopRecording(); + this.stopRecording(); } } + playerComp.addChunk(data); }; @@ -1136,7 +941,7 @@ Component({ filename: this.data.flvFilename, params: this.data.flvParams, }, - // msgCallback, // 不传 msgCallback 就是保持之前设置的 + msgCallback, dataCallback, }) .then((res) => { @@ -1144,7 +949,7 @@ Component({ // 已经stop了 return; } - console.log(`[${this.data.innerId}]`, '==== startStream res', res); + this.console.log(`[${this.data.innerId}]`, '==== startStream res', res); if (res === 0) { this.dataCallback = dataCallback; this.changeState({ @@ -1161,25 +966,14 @@ Component({ // 已经stop了 return; } - console.log(`[${this.data.innerId}]`, '==== startStream error', res); + this.console.error(`[${this.data.innerId}]`, '==== startStream error', res); this.resetStreamData(StreamStateEnum.StreamStartError); this.tryStopPlayer(); - this.handlePlayError(StreamStateEnum.StreamStartError, { msg: `startStream err ${errcode}` }); + this.handlePlayError(StreamStateEnum.StreamStartError, { msg: `startStream err ${res.errcode}` }); }); }, - refreshBytesDelay() { - if (this.refreshTimer) { - return; - } - this.refreshTimer = setTimeout(() => { - this.refreshTimer = null; - this.setData({ - totalBytes: this.userData?.totalBytes || 0, // 把数据更新到界面 - }); - }, 1000); - }, stopStream(newStreamState = StreamStateEnum.StreamIdle) { - console.log(`[${this.data.innerId}]`, `stopStream, ${this.data.streamState} -> ${newStreamState}`); + this.console.log(`[${this.data.innerId}]`, `stopStream, ${this.data.streamState} -> ${newStreamState}`); // 记下来,因为resetStreamData会把这个改成false const needStopStream = this.data.playing; @@ -1190,34 +984,35 @@ Component({ this.cancelRecording(); // 拉流中的才需要 xp2pManager.stopStream - console.log(`[${this.data.innerId}]`, 'do stopStream', this.properties.targetId); - xp2pManager.stopStream(this.properties.targetId); + this.console.log(`[${this.data.innerId}]`, 'do stopStream', this.properties.targetId, this.data.streamType); + xp2pManager.stopStream(this.properties.targetId, this.data.streamType); } }, - changeFlv({ filename = '', params = '' }) { - console.log(`[${this.data.innerId}]`, 'changeFlv', filename, params); + changeFlv({ params = '' }) { + this.console.log(`[${this.data.innerId}]`, 'changeFlv', params); + const streamType = getParamValue(params, 'action') || 'live'; this.setData( { - flvFile: `${filename}${params ? `?${params}` : ''}`, - flvFilename: filename, + flvFile: `${this.data.flvFilename}${params ? `?${params}` : ''}`, flvParams: params, + streamType, }, () => { // 停掉现在的的 - console.log(`[${this.data.innerId}]`, 'changeFlv, stop stream and player'); + this.console.log(`[${this.data.innerId}]`, 'changeFlv, stop stream and player'); this.stopStream(); this.tryStopPlayer(); const checkIsFlvValid = this.properties.checkFunctions && this.properties.checkFunctions.checkIsFlvValid; if (checkIsFlvValid && !checkIsFlvValid({ filename: this.data.flvFilename, params: this.data.flvParams })) { - console.log(`[${this.data.innerId}]`, 'flv invalid, return'); + this.console.warn(`[${this.data.innerId}]`, 'flv invalid, return'); // 无效,停止播放 return; } // 有效,触发播放 - console.log(`[${this.data.innerId}]`, '==== trigger play', this.data.flvFilename, this.data.flvParams); - this.makeResultParams({ startAction: 'changeFlv', flvParams: params }); + this.console.log(`[${this.data.innerId}]`, '==== trigger play', this.data.flvFilename, this.data.flvParams); + this.makeResultParams({ startAction: 'changeFlv', flvParams: this.data.flvParams }); this.tryTriggerPlay('changeFlv'); }, ); @@ -1242,7 +1037,7 @@ Component({ return; } - console.log(`[${this.data.innerId}]`, 'stopAll', newP2PState); + this.console.log(`[${this.data.innerId}]`, 'stopAll', newP2PState); // 不用等stopPlay的回调,先把流停掉 let newStreamState = StreamStateEnum.StreamIdle; @@ -1258,8 +1053,29 @@ Component({ this.tryStopPlayer(); }, + reset() { + if (!this.data.canUseP2P) { + // 不可用的不需要reset + return; + } + + this.console.log(`[${this.data.innerId}]`, 'reset in state', this.data.playerState, this.data.p2pState, this.data.streamState); + + // 一般是 isFatalError 之后重来,msg 保留 + const oldMsg = this.data.playerMsg; + + // 所有的状态都重置 + this.changeState({ + hasPlayer: false, + playerDenied: false, + playerState: PlayerStateEnum.PlayerIdle, + p2pState: P2PStateEnum.P2PIdle, + streamState: StreamStateEnum.StreamIdle, + playerMsg: oldMsg, + }); + }, pause({ success, fail, complete, needPauseStream = false }) { - console.log(`[${this.data.innerId}] pause, hasPlayerCrx: ${!!this.data.playerCtx}, needPauseStream ${needPauseStream}`); + this.console.log(`[${this.data.innerId}] pause, hasPlayerCrx: ${!!this.data.playerCtx}, needPauseStream ${needPauseStream}`); if (!this.data.playerCtx) { fail && fail({ errMsg: 'player not ready' }); complete && complete(); @@ -1268,10 +1084,10 @@ Component({ if (!needPauseStream) { // 真的pause - console.log(`[${this.data.innerId}] playerCtx.pause`); + this.console.log(`[${this.data.innerId}] playerCtx.pause`); this.data.playerCtx.pause({ success: () => { - console.log(`[${this.data.innerId}] playerCtx.pause success`); + this.console.log(`[${this.data.innerId}] playerCtx.pause success`); this.setData({ playerPaused: true, needPauseStream: false, @@ -1289,10 +1105,10 @@ Component({ playerPaused: 'stopped', needPauseStream: true, }); - console.log(`[${this.data.innerId}] playerCtx.stop`); + this.console.log(`[${this.data.innerId}] playerCtx.stop`); this.data.playerCtx.stop({ complete: () => { - console.log(`[${this.data.innerId}] playerCtx.stop success`); + this.console.log(`[${this.data.innerId}] playerCtx.stop success`); this.setData({ playerPaused: 'stopped', needPauseStream: true, @@ -1304,17 +1120,17 @@ Component({ } }, resume({ success, fail, complete }) { - console.log(`[${this.data.innerId}] resume, hasPlayerCrx: ${!!this.data.playerCtx}`); + this.console.log(`[${this.data.innerId}] resume, hasPlayerCrx: ${!!this.data.playerCtx}`); if (!this.data.playerCtx) { fail && fail({ errMsg: 'player not ready' }); complete && complete(); return; } const funcName = this.data.playerPaused === 'stopped' ? 'play' : 'resume'; - console.log(`[${this.data.innerId}] playerCtx.${funcName}`); + this.console.log(`[${this.data.innerId}] playerCtx.${funcName}`); this.data.playerCtx[funcName]({ success: () => { - console.log(`[${this.data.innerId}] playerCtx.${funcName} success, needPauseStream ${this.data.needPauseStream}`); + this.console.log(`[${this.data.innerId}] playerCtx.${funcName} success, needPauseStream ${this.data.needPauseStream}`); this.setData({ playerPaused: false, // needPauseStream: false, // 还不能接收数据,seek之后才行,外层主动调用resumeStream修改 @@ -1327,7 +1143,7 @@ Component({ }, resumeStream() { const { needPauseStream, firstChunkDataInPaused } = this.data; - console.log(`[${this.data.innerId}] resumeStream, has first chunk data ${!!firstChunkDataInPaused}`); + this.console.log(`[${this.data.innerId}] resumeStream, has first chunk data ${!!firstChunkDataInPaused}`); this.setData({ needPauseStream: false, firstChunkDataInPaused: null, @@ -1353,7 +1169,7 @@ Component({ }, tryTriggerPlay(reason) { const isReplay = reason === 'replay'; - console.log( + this.console.log( `[${this.data.innerId}]`, '==== tryTriggerPlay', '\n reason', reason, '\n playerState', this.data.playerState, @@ -1362,11 +1178,8 @@ Component({ ); // 这个要放在上面,否则回放时的统计不对 - if (this.data.p2pState === P2PStateEnum.ServiceStarted && this.data.playerState === PlayerStateEnum.PlayerReady - && this.data.playResultParams - && !this.data.playResultParams.playTimestamps.bothReady - ) { - this.addStateTimestamp('bothReady'); + if (this.data.p2pState === P2PStateEnum.ServiceStarted && this.data.playerState === PlayerStateEnum.PlayerReady) { + this.stat.addStateTimestamp('bothReady', { onlyOnce: true }); } const isP2PStateCanPlay = this.data.p2pState === P2PStateEnum.ServiceStarted; @@ -1377,13 +1190,13 @@ Component({ || this.data.playerState === PlayerStateEnum.LivePlayerStateError; } if (!isP2PStateCanPlay || !this.data.playerCtx || !isPlayerStateCanPlay) { - console.log(`[${this.data.innerId}]`, 'state can not play, return'); + this.console.log(`[${this.data.innerId}]`, 'state can not play, return'); return; } const checkIsFlvValid = this.properties.checkFunctions && this.properties.checkFunctions.checkIsFlvValid; if (checkIsFlvValid && !checkIsFlvValid({ filename: this.data.flvFilename, params: this.data.flvParams })) { - console.log(`[${this.data.innerId}]`, 'flv invalid, return'); + this.console.warn(`[${this.data.innerId}]`, 'flv invalid, return'); return; } @@ -1397,16 +1210,16 @@ Component({ }); if (this.data.needPlayer && !this.data.autoPlay) { // 用 autoPlay 是因为有时候成功调用了play,但是live-player实际并没有开始播放 - console.log(`[${this.data.innerId}]`, '==== trigger play by autoPlay'); + this.console.log(`[${this.data.innerId}]`, '==== trigger play by autoPlay'); this.setData({ autoPlay: true }); } else { - console.log(`[${this.data.innerId}]`, '==== trigger play by playerCtx'); + this.console.log(`[${this.data.innerId}]`, '==== trigger play by playerCtx'); this.data.playerCtx.play({ success: (res) => { - console.log(`[${this.data.innerId}]`, 'call play success', res); + this.console.log(`[${this.data.innerId}]`, 'call play success', res); }, fail: (res) => { - console.log(`[${this.data.innerId}]`, 'call play fail', res); + this.console.log(`[${this.data.innerId}]`, 'call play fail', res); }, }); } @@ -1421,6 +1234,9 @@ Component({ if (wx.getSystemInfoSync().platform === 'devtools') { // 开发者工具里不支持 live-player 和 TCPServer,明确提示 msg = '不支持在开发者工具中创建p2p-player'; + } else if (this.data.playerDenied) { + // 如果liveplayer是RTC模式,当微信没有系统录音权限时会出错 + msg = '请开启微信的系统录音权限'; } isFatalError = true; } else if (this.data.p2pState === P2PStateEnum.P2PInitError) { @@ -1440,8 +1256,14 @@ Component({ isFatalError = true; } if (isFatalError) { - // 不可恢复错误,退出重来 - console.log(`[${this.data.innerId}] ${errType} isFatalError, trigger playError`); + // 不可恢复错误,销毁player + if (!this.isP2PInErrorState(this.data.p2pState)) { + this.stopAll(); + } + if (this.data.hasPlayer) { + this.changeState({ hasPlayer: false }); + } + this.console.log(`[${this.data.innerId}] ${errType} isFatalError, trigger playError`); this.triggerEvent('playError', { errType, errMsg: totalMsgMap[errType], @@ -1459,7 +1281,7 @@ Component({ } // 自动重新开始 - console.log(`[${this.data.innerId}]`, 'auto replay'); + this.console.log(`[${this.data.innerId}]`, 'auto replay'); this.stopStream(newStreamState); this.tryStopPlayer({ @@ -1467,21 +1289,34 @@ Component({ this.changeState({ streamState: StreamStateEnum.StreamWaitPull, }); - console.log(`[${this.data.innerId}]`, 'trigger replay'); + this.console.log(`[${this.data.innerId}]`, 'trigger replay'); this.data.playerCtx.play(); }, }); }, // 手动retry onClickRetry() { - if (this.data.playerState !== PlayerStateEnum.PlayerReady) { - // player 没ready不能retry - console.log(`[${this.data.innerId}]`, `can not retry in ${this.data.playerState}`); + if (!this.data.canUseP2P) { + // 不可用的不需要reset return; } + + let needCreatePlayer; + if (this.data.playerState !== PlayerStateEnum.PlayerReady) { + if (this.data.needPlayer && this.data.canUseP2P && !this.data.hasPlayer + && this.data.playerState === PlayerStateEnum.PlayerIdle + ) { + // reset了 + needCreatePlayer = true; + } else { + // player 没ready不能retry + this.console.log(`[${this.data.innerId}]`, `can not retry in ${this.data.playerState}`); + return; + } + } if (this.data.playing || this.data.streamState === StreamStateEnum.StreamWaitPull) { // 播放中不能retry - console.log(`[${this.data.innerId}]`, `can not retry in ${this.data.playing ? 'playing' : this.data.streamState}`); + this.console.log(`[${this.data.innerId}]`, `can not retry in ${this.data.playing ? 'playing' : this.data.streamState}`); return; } @@ -1489,8 +1324,13 @@ Component({ return; } - console.log(`[${this.data.innerId}]`, 'click retry'); + this.console.log(`[${this.data.innerId}]`, 'click retry'); this.makeResultParams({ startAction: 'clickRetry' }); + if (needCreatePlayer) { + this.changeState({ + hasPlayer: true, + }); + } if (this.data.p2pState === P2PStateEnum.ServiceStarted) { this.tryTriggerPlay('clickRetry'); } else if (this.data.p2pState === P2PStateEnum.P2PInited @@ -1524,9 +1364,33 @@ Component({ this.tryStopPlayer(); } }, + onP2PServiceMessage(targetId, event, subtype, detail) { + if (targetId !== this.properties.targetId) { + this.console.warn( + `[${this.data.innerId}]`, + `onP2PServiceMessage, targetId error, now ${this.properties.targetId}, receive`, + targetId, + event, + subtype, + ); + return; + } + + switch (event) { + case XP2PServiceEventEnum.ServiceNotify: + this.onP2PServiceNotify(subtype, detail); + break; + + default: + this.console.warn(`[${this.data.innerId}]`, 'onP2PServiceMessage, unknown event', event, subtype); + } + }, + onP2PServiceNotify(type, detail) { + this.console.info(`[${this.data.innerId}]`, 'onP2PServiceNotify', type, detail); + }, onP2PMessage(targetId, event, subtype, detail) { if (targetId !== this.properties.targetId) { - console.warn( + this.console.warn( `[${this.data.innerId}]`, `onP2PMessage, targetId error, now ${this.properties.targetId}, receive`, targetId, @@ -1550,21 +1414,22 @@ Component({ break; default: - console.log(`[${this.data.innerId}]`, 'onP2PMessage, unknown event', event, subtype); + this.console.warn(`[${this.data.innerId}]`, 'onP2PMessage, unknown event', event, subtype); } }, onP2PMessage_Notify(type, detail) { - console.log(`[${this.data.innerId}]`, 'onP2PMessage_Notify', type, detail); - let detailMsg; + this.console.info(`[${this.data.innerId}]`, 'onP2PMessage_Notify', type, detail); + if (!this.data.playing && type !== XP2PNotify_SubType.Close) { + // 退出播放时 stopStream 会触发 close 事件,是正常的,其他事件才 warn + this.console.warn(`[${this.data.innerId}] receive onP2PMessage_Notify when not playing`, type, detail); + } switch (type) { case XP2PNotify_SubType.Connected: - // 注意不要修改state,Connected只在心跳保活时可能收到,不在关键路径上,只是记录一下 + // stream连接成功,注意不要修改state,Connected只在心跳保活时可能收到,不在关键路径上,只是记录一下 this.setData({ p2pConnected: true, }); - if (this.data.playResultParams && !this.data.playResultParams.playTimestamps.p2pConnected) { - this.addStateTimestamp('p2pConnected'); - } + this.stat.addStateTimestamp('p2pConnected', { onlyOnce: true }); break; case XP2PNotify_SubType.Request: this.changeState({ @@ -1572,43 +1437,62 @@ Component({ }); break; case XP2PNotify_SubType.Parsed: - // 数据传输开始 - this.changeState({ - streamState: StreamStateEnum.StreamHeaderParsed, - }); + if (!detail || detail.status === 200) { + // 数据传输开始 + this.changeState({ + streamState: StreamStateEnum.StreamHeaderParsed, + }); + } else { + this.resetStreamData(StreamStateEnum.StreamHttpStatusError); + this.tryStopPlayer(); + const msg = httpStatusErrorMsgMap[detail.status] || `httpStatus: ${detail.status}`; + this.handlePlayError(StreamStateEnum.StreamHttpStatusError, { msg, detail }); + } break; case XP2PNotify_SubType.Success: case XP2PNotify_SubType.Eof: - // 数据传输正常结束 - console.log(`[${this.data.innerId}]`, - `==== Notify ${type} in p2pState ${this.data.p2pState}, chunkCount ${this.userData.chunkCount}, time after last chunk ${Date.now() - this.userData.chunkTime}`, - detail, - ); - this.handlePlayEnd(StreamStateEnum.StreamDataEnd); + { + // 数据传输正常结束 + this.console.log(`[${this.data.innerId}]`, + `==== Notify ${type} in p2pState ${this.data.p2pState}, chunkCount ${this.userData.chunkCount}, time after last chunk ${Date.now() - this.userData.chunkTime}`, + detail, + ); + const { livePlayerInfo } = this.userData; + const cache = Math.max(livePlayerInfo?.videoCache || 0, livePlayerInfo?.audioCache || 0); + if (cache> cacheIgnore) { + // 播放器还有cache,先不处理,打个log + this.console.warn(`[${this.data.innerId}] data end but player has cache: video ${livePlayerInfo?.videoCache}, audio ${livePlayerInfo?.audioCache}`, livePlayerInfo); + } + this.handlePlayEnd(StreamStateEnum.StreamDataEnd); + } break; case XP2PNotify_SubType.Fail: // 数据传输出错 - console.error(`[${this.data.innerId}]`, `==== Notify ${type} in p2pState ${this.data.p2pState}`, detail); + this.console.error(`[${this.data.innerId}]`, `==== Notify ${type} in p2pState ${this.data.p2pState}`, detail); this.handlePlayEnd(StreamStateEnum.StreamError); break; case XP2PNotify_SubType.Close: - if (!this.data.playing) { - // 用户主动关闭,或者因为隐藏等原因挂起了,都会收到 onPlayerClose - return; + { + if (!this.data.playing) { + // 用户主动关闭,或者因为隐藏等原因挂起了,都会收到 onPlayerClose + return; + } + // 播放中收到了Close,当作播放失败 + const detailMsg = typeof detail === 'string' ? detail : (detail && detail.type); + this.handlePlayError(StreamStateEnum.StreamError, { msg: `p2pNotify: ${type}, ${detailMsg}`, detail }); } - // 播放中收到了Close,当作播放失败 - detailMsg = typeof detail === 'string' ? detail : (detail && detail.type); - this.handlePlayError(StreamStateEnum.StreamError, { msg: `p2pNotify: ${type}, ${detailMsg}`, detail }); break; case XP2PNotify_SubType.Disconnect: - // p2p链路断开 - console.error(`[${this.data.innerId}]`, `XP2PNotify_SubType.Disconnect in p2pState ${this.data.p2pState}`, detail); - this.setData({ - p2pConnected: false, - }); - this.stopAll(P2PStateEnum.ServiceError); - detailMsg = typeof detail === 'string' ? detail : (detail && detail.type); - this.handlePlayError(P2PStateEnum.ServiceError, { msg: `p2pNotify: ${type}, ${detailMsg}`, detail }); + { + // p2p流断开 + this.console.error(`[${this.data.innerId}]`, `XP2PNotify_SubType.Disconnect in p2pState ${this.data.p2pState}`, detail); + this.setData({ + p2pConnected: false, + }); + this.stopAll(P2PStateEnum.ServiceError); + const detailMsg = typeof detail === 'string' ? detail : (detail && detail.type); + this.handlePlayError(P2PStateEnum.ServiceError, { msg: `p2pNotify: ${type}, ${detailMsg}`, detail }); + } break; } }, @@ -1618,11 +1502,41 @@ Component({ muted: !this.data.muted, }); }, + changeSoundMode() { + this.setData({ + soundMode: this.data.soundMode === 'ear' ? 'speaker' : 'ear', + }); + }, changeOrientation() { this.setData({ orientation: this.data.orientation === 'horizontal' ? 'vertical' : 'horizontal', }); }, + snapshotAndSave() { + this.console.log(`[${this.data.innerId}]`, 'snapshotAndSave'); + snapshotAndSave({ + snapshot: this.snapshot.bind(this), + }); + }, + snapshot() { + this.console.log(`[${this.data.innerId}]`, 'snapshot'); + if (!this.data.playerCtx) { + return Promise.reject({ errMsg: 'player not ready' }); + } + return new Promise((resolve, reject) => { + this.data.playerCtx.snapshot({ + quality: 'raw', + success: (res) => { + this.console.log(`[${this.data.innerId}]`, 'snapshot success', res); + resolve(res); + }, + fail: (err) => { + this.console.error(`[${this.data.innerId}]`, 'snapshot fail', err); + reject(err); + }, + }); + }); + }, // 以下是调试面板相关的 toggleDebugInfo() { this.setData({ showDebugInfo: !this.data.showDebugInfo }); @@ -1650,7 +1564,7 @@ Component({ if (!modalRes || !modalRes.confirm) { return; } - console.log(`[${this.data.innerId}] confirm startRecording`); + this.console.log(`[${this.data.innerId}] confirm startRecording`); // 保存录像文件要有flv头,停掉重新拉流 if (this.data.playing) { @@ -1661,11 +1575,11 @@ Component({ // 准备录像文件,注意要在 stopStream 之后 let realRecordFilename = recordFilename; if (!realRecordFilename) { - if (this.data.mode === 'ipc') { + if (this.data.p2pMode === 'ipc') { const streamType = getParamValue(this.data.flvParams, 'action') || 'live'; - realRecordFilename = `${this.data.mode}-${this.properties.productId}-${this.properties.deviceName}-${streamType}`; + realRecordFilename = `${this.data.p2pMode}-${this.properties.productId}-${this.properties.deviceName}-${streamType}`; } else { - realRecordFilename = `${this.data.mode}-${this.data.flvFilename}`; + realRecordFilename = `${this.data.p2pMode}-${this.data.flvFilename}`; } } const fileObj = recordManager.openRecordFile(realRecordFilename); @@ -1673,13 +1587,13 @@ Component({ this.setData({ isRecording: !!fileObj, }); - console.log(`[${this.data.innerId}] record fileName ${fileObj && fileObj.fileName}`); + this.console.log(`[${this.data.innerId}] record fileName ${fileObj && fileObj.fileName}`); // 重新play this.changeState({ streamState: StreamStateEnum.StreamWaitPull, }); - console.log(`[${this.data.innerId}]`, 'trigger record play'); + this.console.log(`[${this.data.innerId}]`, 'trigger record play'); this.data.playerCtx.play(); }, async stopRecording() { @@ -1688,15 +1602,15 @@ Component({ return; } - console.log(`[${this.data.innerId}]`, `stopRecording, ${this.userData.fileObj.fileName}`); + this.console.log(`[${this.data.innerId}]`, `stopRecording, ${this.userData.fileObj.fileName}`); const { fileObj } = this.userData; - this.userData = null; + this.userData.fileObj = null; this.setData({ isRecording: false, }); const fileRes = recordManager.saveRecordFile(fileObj); - console.log(`[${this.data.innerId}]`, 'saveRecordFile res', fileRes); + this.console.log(`[${this.data.innerId}]`, 'saveRecordFile res', fileRes); if (!fileRes) { wx.showToast({ @@ -1727,9 +1641,9 @@ Component({ return; } - console.log(`[${this.data.innerId}]`, `cancelRecording, ${this.userData.fileObj.fileName}`); + this.console.log(`[${this.data.innerId}]`, `cancelRecording, ${this.userData.fileObj.fileName}`); const { fileObj } = this.userData; - this.userData = null; + this.userData.fileObj = null; this.setData({ isRecording: false, }); diff --git a/demo/miniprogram/components/iot-p2p-common-player/player.wxml b/demo/miniprogram/components/iot-p2p-common-player/player.wxml index ee29b50..6094f3c 100644 --- a/demo/miniprogram/components/iot-p2p-common-player/player.wxml +++ b/demo/miniprogram/components/iot-p2p-common-player/player.wxml @@ -1,36 +1,38 @@ - + + - - - totalBytes: {{totalBytes}} - - - playerState: {{playerState}} - - {{playerMsg}} - + {{playerPaused ? 'paused' : ''}} {{muted ? 'muted' : 'notMuted'}} {{orientation}} + snapshot @@ -46,15 +48,16 @@ {{flvFile}} + playerMode: {{mode}} playerState: {{playerState}} pauseType: {{playerPaused}} p2pState: {{p2pState}} - streamState: {{streamState}} {{playing ? 'playing' : ''}} + streamState: {{streamState}} - totalBytes: {{totalBytes}} + playing: {{playing}} {{isSlow ? '恢复' : '模拟丢包'}} - record: + record: {{isRecording ? '停止录像' : '开始录像'}} 开始播放后才可以录像 @@ -73,4 +76,4 @@ - \ No newline at end of file + diff --git a/demo/miniprogram/components/iot-p2p-common-player/player.wxss b/demo/miniprogram/components/iot-p2p-common-player/player.wxss index c583f01..fa38bda 100644 --- a/demo/miniprogram/components/iot-p2p-common-player/player.wxss +++ b/demo/miniprogram/components/iot-p2p-common-player/player.wxss @@ -1,5 +1,10 @@ @import '../../common.wxss'; +.iot-player { + box-sizing: border-box; + position: relative; +} + .player-container { box-sizing: border-box; position: relative; @@ -11,13 +16,6 @@ width: 100% !important; height: 100% !important; } -.player-container .mock-player { - box-sizing: border-box; - color: white; - width: 100%; - height: 100%; - padding: 20rpx; -} .player-container .player-message { position: absolute; @@ -66,7 +64,7 @@ margin-right: 20rpx; } -.debug-info-container { +.iot-player .debug-info-container { position: absolute; width: 100%; max-height: 720rpx; diff --git a/demo/miniprogram/components/iot-p2p-common-player/stat.js b/demo/miniprogram/components/iot-p2p-common-player/stat.js new file mode 100644 index 0000000..167f610 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-common-player/stat.js @@ -0,0 +1,220 @@ +import { PlayerStateEnum, P2PStateEnum, StreamStateEnum } from './common'; + +// 启播步骤 +export const PlayStepEnum = { + CreatePlayer: 'StepCreatePlayer', + InitModule: 'StepInitModule', + StartP2PService: 'StepStartP2PService', + // WaitBothReady: 'StepWaitBothReady', + // WaitTriggerPlay: 'StepWaitTriggerPlay', // 回放时等待选择录像的时间,不需要了,回放从选择录像开始计时 + ConnectLocalServer: 'StepConnectLocalServer', + WaitStream: 'StepWaitStream', + CheckStream: 'StepCheckStream', + StartStream: 'StepStartStream', + WaitHeader: 'StepWaitHeader', + WaitData: 'StepWaitData', + WaitIDR: 'StepWaitIDR', + AutoReconnect: 'StepAutoReconnect', // 没正常播放,liveplayer自动重连,2103 + FinalStop: 'StepFinalStop', // 多次重连抢救无效,-2301 +}; + +const state2StepConfig = { + // player + [PlayerStateEnum.PlayerReady]: { + step: PlayStepEnum.CreatePlayer, + fromState: PlayerStateEnum.PlayerPreparing, + toState: PlayerStateEnum.PlayerReady, + }, + [PlayerStateEnum.PlayerError]: { + step: PlayStepEnum.CreatePlayer, + fromState: PlayerStateEnum.PlayerPreparing, + toState: PlayerStateEnum.PlayerError, + isResult: true, + }, + + // p2p + [P2PStateEnum.P2PInited]: { + step: PlayStepEnum.InitModule, + fromState: P2PStateEnum.P2PIniting, + toState: P2PStateEnum.P2PInited, + }, + [P2PStateEnum.P2PInitError]: { + step: PlayStepEnum.InitModule, + fromState: P2PStateEnum.P2PIniting, + toState: P2PStateEnum.P2PInitError, + isResult: true, + }, + [P2PStateEnum.ServiceStarted]: { + step: PlayStepEnum.StartP2PService, + fromState: P2PStateEnum.ServicePreparing, + toState: P2PStateEnum.ServiceStarted, + }, + [P2PStateEnum.ServiceStartError]: { + step: PlayStepEnum.StartP2PService, + fromState: P2PStateEnum.ServicePreparing, + toState: P2PStateEnum.ServiceStartError, + isResult: true, + }, + + // stream + [StreamStateEnum.StreamReceivePull]: { + step: PlayStepEnum.ConnectLocalServer, + fromState: StreamStateEnum.StreamWaitPull, + toState: StreamStateEnum.StreamReceivePull, + }, + [StreamStateEnum.StreamLocalServerError]: { + step: PlayStepEnum.ConnectLocalServer, + fromState: StreamStateEnum.StreamWaitPull, + toState: StreamStateEnum.StreamLocalServerError, + }, + [StreamStateEnum.StreamCheckSuccess]: { + step: PlayStepEnum.CheckStream, + fromState: StreamStateEnum.StreamChecking, + toState: StreamStateEnum.StreamCheckSuccess, + }, + [StreamStateEnum.StreamCheckError]: { + step: PlayStepEnum.CheckStream, + fromState: StreamStateEnum.StreamChecking, + toState: StreamStateEnum.StreamCheckError, + isResult: true, + }, + [StreamStateEnum.StreamStarted]: { + step: PlayStepEnum.StartStream, + fromState: StreamStateEnum.StreamPreparing, + toState: StreamStateEnum.StreamStarted, + }, + [StreamStateEnum.StreamStartError]: { + step: PlayStepEnum.StartStream, + fromState: StreamStateEnum.StreamPreparing, + toState: StreamStateEnum.StreamStartError, + isResult: true, + }, + [StreamStateEnum.StreamHeaderParsed]: { + step: PlayStepEnum.WaitHeader, + fromState: StreamStateEnum.StreamStarted, + toState: StreamStateEnum.StreamHeaderParsed, + }, + [StreamStateEnum.StreamDataReceived]: { + step: PlayStepEnum.WaitData, + fromState: StreamStateEnum.StreamHeaderParsed, + toState: StreamStateEnum.StreamDataReceived, + isResult: true, + isSuccess: true, + }, + [StreamStateEnum.StreamError]: { + step: PlayStepEnum.WaitStream, + fromState: StreamStateEnum.StreamStarted, + toState: StreamStateEnum.StreamError, + isResult: true, + }, +}; + +export class PlayStat { + innerId; + console; + onPlayStepsChange; + onPlayResultChange; + onIdrResultChange; + + playResultParams; + idrResultParams; + + constructor({ innerId, console, onPlayStepsChange, onPlayResultChange, onIdrResultChange }) { + this.innerId = innerId; + this.console = console; + this.onPlayStepsChange = onPlayStepsChange; + this.onPlayResultChange = onPlayResultChange; + this.onIdrResultChange = onIdrResultChange; + } + + makeResultParams({ startAction, flvParams }) { + this.console.log(`[${this.innerId}][stat]`, '==== start new play', startAction, flvParams); + const now = Date.now(); + this.playResultParams = { + startAction, + flvParams, + startTimestamp: now, + lastTimestamp: now, + playTimestamps: {}, + steps: [], + result: null, + }; + this.idrResultParams = null; + } + + addStateTimestamp(state, { onlyOnce } = {}) { + if (!state || !this.playResultParams || this.playResultParams.result) { + return; + } + if (onlyOnce && this.playResultParams.playTimestamps[state]) { + return; + } + this.playResultParams.playTimestamps[state] = Date.now(); + const stepCfg = state2StepConfig[state]; + if (stepCfg) { + this.addStep(stepCfg.step, stepCfg); + } + } + + addStep(step, { fromState, toState, isResult, isSuccess } = {}) { + if (!step || !this.playResultParams || this.playResultParams.result) { + return; + } + const now = Date.now(); + const { playTimestamps } = this.playResultParams; + let fromTime = 0; + let toTime = 0; + if (fromState) { + if (!playTimestamps[fromState]) { + this.console.warn(`[${this.innerId}][stat]`, 'addStep', step, 'but no fromState', fromState); + return; + } + fromTime = playTimestamps[fromState]; + } else { + fromTime = this.playResultParams.lastTimestamp; + } + if (toState) { + if (!playTimestamps[toState]) { + this.console.warn(`[${this.innerId}][stat]`, 'addStep', step, 'but no toState', toState); + return; + } + toTime = playTimestamps[toState]; + } else { + toTime = now; + } + + const timeCost = toTime - fromTime; + this.console.log(`[${this.innerId}][stat]`, 'addStep', step, timeCost, fromState ? `${fromState} -> ${toState || 'now'}` : ''); + this.playResultParams.lastTimestamp = now; + this.playResultParams.steps.push({ + step, + timeCost, + }); + + if (isResult) { + this.playResultParams.result = toState; + const { startAction, startTimestamp, result } = this.playResultParams; + const totalTimeCost = now - startTimestamp; + this.console.log(`[${this.innerId}][stat]`, '==== play result', startAction, step, result, totalTimeCost, this.playResultParams); + if (isSuccess) { + this.idrResultParams = { + hasReceivedIDR: false, + playSuccTime: now, + }; + } + this.onPlayResultChange(this.playResultParams); + } else { + this.onPlayStepsChange(this.playResultParams); + } + } + + receiveIDR() { + if (!this.idrResultParams || this.idrResultParams.hasReceivedIDR) { + return; + } + const timeCost = Date.now() - this.idrResultParams.playSuccTime; + this.idrResultParams.hasReceivedIDR = true; + this.idrResultParams.timeCost = timeCost; + this.onIdrResultChange(this.idrResultParams); + } +} diff --git a/demo/miniprogram/components/iot-p2p-common-pusher/common.js b/demo/miniprogram/components/iot-p2p-common-pusher/common.js new file mode 100644 index 0000000..424668f --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-common-pusher/common.js @@ -0,0 +1,24 @@ +// ts才能用enum,先这么处理吧 +export const PusherStateEnum = { + PusherIdle: 'PusherIdle', + PusherPreparing: 'PusherPreparing', + PusherReady: 'PusherReady', + PusherError: 'PusherError', + LivePusherError: 'LivePusherError', + LivePusherStateError: 'LivePusherStateError', + LocalServerError: 'LocalServerError', +}; + +export const totalMsgMap = { + [PusherStateEnum.PusherPreparing]: '正在创建Pusher...', + [PusherStateEnum.PusherReady]: '创建Pusher成功', + [PusherStateEnum.PusherError]: '创建Pusher失败', + [PusherStateEnum.LivePusherError]: 'LivePusher错误', + [PusherStateEnum.LivePusherStateError]: '推流失败', + [PusherStateEnum.LocalServerError]: '本地RtmpServer错误', + PusherPushing: '推流中...', +}; + +export const livePusherErrMsgMap = { + 10002: '对讲需要您授权小程序使用麦克风', // 用户禁止使用录音 +}; diff --git a/demo/miniprogram/components/iot-p2p-common-pusher/pusher.js b/demo/miniprogram/components/iot-p2p-common-pusher/pusher.js index e628f80..cfcac2d 100644 --- a/demo/miniprogram/components/iot-p2p-common-pusher/pusher.js +++ b/demo/miniprogram/components/iot-p2p-common-pusher/pusher.js @@ -1,34 +1,18 @@ import { getXp2pManager } from '../../lib/xp2pManager'; +import { PusherStateEnum, totalMsgMap, livePusherErrMsgMap } from './common'; const xp2pManager = getXp2pManager(); -// ts才能用enum,先这么处理吧 -const PusherStateEnum = { - PusherIdle: 'PusherIdle', - PusherPreparing: 'PusherPreparing', - PusherReady: 'PusherReady', - PusherError: 'PusherError', - LivePusherError: 'LivePusherError', - LivePusherStateError: 'LivePusherStateError', - LocalServerError: 'LocalServerError', -}; - -const totalMsgMap = { - [PusherStateEnum.PusherPreparing]: '正在创建Pusher...', - [PusherStateEnum.PusherReady]: '创建Pusher成功', - [PusherStateEnum.PusherError]: '创建Pusher失败', - [PusherStateEnum.LivePusherError]: 'LivePusher错误', - [PusherStateEnum.LivePusherStateError]: '推流失败', - [PusherStateEnum.LocalServerError]: '本地RtmpServer错误', - PusherPushing: '推流中...', -}; - let pusherSeq = 0; Component({ behaviors: ['wx://component-export'], properties: { // 以下是 live-pusher 的属性 + mode: { + type: String, // RTC / SD / HD / FHD + value: 'RTC', + }, enableCamera: { type: Boolean, value: true, @@ -37,6 +21,23 @@ Component({ type: Boolean, value: true, }, + enableAgc: { + type: Boolean, + value: true, + }, + enableAns: { + type: Boolean, + value: true, + }, + audioQuality: { + type: String, + value: 'low', + }, + // 以下是自己的属性 + needLivePusherInfo: { + type: Boolean, + value: false, + }, }, data: { innerId: '', @@ -48,6 +49,13 @@ Component({ pusherComp: null, pusherCtx: null, pusherMsg: '', + acceptLivePusherEvents: { + // 太多事件log了,只接收这几个 + error: true, + statechange: true, + netstatus: false, // attached 时根据 needLivePusherInfo 赋值 + // audiovolumenotify: true, + }, // 有writer才能推流 hasWriter: false, @@ -82,6 +90,10 @@ Component({ this.setData({ hasPusher: true, pusherId, + acceptLivePusherEvents: { + ...this.data.acceptLivePusherEvents, + netstatus: this.properties.needLivePusherInfo || false, + } }); this.createPusher(); @@ -178,7 +190,7 @@ Component({ }, onPusherError({ detail }) { console.error(`[${this.data.innerId}]`, '==== onPusherError', detail); - const code = detail && detail.error && detail.error.code; + const code = detail?.error?.code; let pusherState = PusherStateEnum.PusherError; if (code === 'WECHAT_SERVER_ERROR') { pusherState = PusherStateEnum.LocalServerError; @@ -209,7 +221,7 @@ Component({ }); // 其他错误,比如没有开通live-pusher组件权限 // 参考:https://developers.weixin.qq.com/miniprogram/dev/component/live-pusher.html - this.handlePushError(pusherState, { msg: `livePusherError: ${detail.errMsg}` }); + this.handlePushError(pusherState, { msg: livePusherErrMsgMap[detail.errCode] || `livePusherError: ${detail.errMsg}` }); }, onLivePusherStateChange({ detail }) { // console.log('onLivePusherStateChange', detail); @@ -255,8 +267,8 @@ Component({ console.log(`[${this.data.innerId}]`, 'onLivePusherStateChange', detail.code, detail); } }, - onLivePusherNetStatusChange({ detail }) { - // console.log('onLivePusherNetStatusChange', detail); + onLivePusherNetStatus({ detail }) { + // console.log('onLivePusherNetStatus', detail); if (!this.userData.writer || !detail.info) { return; } @@ -301,7 +313,10 @@ Component({ isFatalError = true; } if (isFatalError) { - // 不可恢复错误,退出重来 + // 不可恢复错误,销毁pusher + if (this.data.hasPusher) { + this.changeState({ hasPusher: false }); + } this.triggerEvent('pushError', { errType, errMsg: totalMsgMap[errType], diff --git a/demo/miniprogram/components/iot-p2p-common-pusher/pusher.wxml b/demo/miniprogram/components/iot-p2p-common-pusher/pusher.wxml index a18cc2b..67a840b 100644 --- a/demo/miniprogram/components/iot-p2p-common-pusher/pusher.wxml +++ b/demo/miniprogram/components/iot-p2p-common-pusher/pusher.wxml @@ -1,15 +1,16 @@ - + {{pusherMsg}} diff --git a/demo/miniprogram/components/iot-p2p-common-pusher/pusher.wxss b/demo/miniprogram/components/iot-p2p-common-pusher/pusher.wxss index fe35a98..58640fd 100644 --- a/demo/miniprogram/components/iot-p2p-common-pusher/pusher.wxss +++ b/demo/miniprogram/components/iot-p2p-common-pusher/pusher.wxss @@ -1,5 +1,10 @@ @import '../../common.wxss'; +.iot-pusher { + box-sizing: border-box; + position: relative; +} + .pusher-container { box-sizing: border-box; position: relative; @@ -60,7 +65,7 @@ margin-right: 20rpx; } -.debug-info-container { +.iot-pusher .debug-info-container { position: absolute; width: 100%; max-height: 720rpx; diff --git a/demo/miniprogram/components/iot-p2p-control/control.js b/demo/miniprogram/components/iot-p2p-control/control.js index fd19d78..c5aa275 100644 --- a/demo/miniprogram/components/iot-p2p-control/control.js +++ b/demo/miniprogram/components/iot-p2p-control/control.js @@ -74,17 +74,19 @@ Component({ // uuid: '', // reset 不用清uuid state: '', localPeername: '', + localPeername2: '' }); }, printData() { console.log(`[${this.id}]`, 'now p2p data', this.data); }, changeState(newData) { - this.triggerEvent('statechange', { state: newData.state, localPeername: newData.localPeername }); + this.triggerEvent('statechange', { state: newData.state, localPeername: newData.localPeername, localPeername2: newData.localPeername2 }); this.setData(newData); }, refreshState() { - if (xp2pManager.state === this.data.state && xp2pManager.localPeername === this.data.localPeername) { + // eslint-disable-next-line max-len + if (xp2pManager.state === this.data.state && xp2pManager.localPeername === this.data.localPeername && xp2pManager.localPeername2 === this.data.localPeername2) { // 无变化 return; } @@ -92,13 +94,14 @@ Component({ uuid: xp2pManager.uuid, state: xp2pManager.state, localPeername: xp2pManager.localPeername, + localPeername2: xp2pManager.localPeername2 }); if (xp2pManager.state === 'initing' || xp2pManager.state === 'reseting') { xp2pManager.promise .then((res) => { if (res === 0) { - this.changeState({ state: 'inited', localPeername: xp2pManager.localPeername }); + this.changeState({ state: 'inited', localPeername: xp2pManager.localPeername, localPeername2: xp2pManager.localPeername2 }); } else { this.destroyModule(); } @@ -117,26 +120,18 @@ Component({ } const start = Date.now(); - this.changeState({ state: 'initing', localPeername: '' }); + this.changeState({ state: 'initing', localPeername: '', localPeername2: '' }); xp2pManager .initModule() .then((res) => { console.log(`[${this.id}]`, 'init res', res); - if (res === 0) { - const now = Date.now(); - console.log(`[${this.id}]`, 'init delay', now - start); - const { localPeername } = xp2pManager; - console.log(`[${this.id}]`, 'localPeername', localPeername); - this.changeState({ state: 'inited', localPeername }); - } else { - this.destroyModule(); - wx.showModal({ - content: `init 失败, res=${res}`, - showCancel: false, - }); - } + const now = Date.now(); + console.log(`[${this.id}]`, 'init delay', now - start); + const { localPeername, localPeername2 } = xp2pManager; + console.log(`[${this.id}]`, 'localPeername', localPeername, localPeername2); + this.changeState({ state: 'inited', localPeername, localPeername2 }); }) .catch((errcode) => { console.error(`[${this.id}]`, 'init error', errcode); @@ -169,26 +164,18 @@ Component({ } const start = Date.now(); - this.changeState({ state: 'reseting', localPeername: '' }); + this.changeState({ state: 'reseting', localPeername: '', localPeername2: '' }); xp2pManager .resetP2P() .then((res) => { console.log(`[${this.id}]`, 'resetP2P res', res); - if (res === 0) { - const now = Date.now(); - console.log(`[${this.id}]`, 'resetP2P delay', now - start); - const { localPeername } = xp2pManager; - console.log(`[${this.id}]`, 'localPeername', localPeername); - this.changeState({ state: 'inited', localPeername }); - } else { - this.destroyModule(); - wx.showModal({ - content: `resetP2P 失败, res=${res}`, - showCancel: false, - }); - } + const now = Date.now(); + console.log(`[${this.id}]`, 'resetP2P delay', now - start); + const { localPeername, localPeername2 } = xp2pManager; + console.log(`[${this.id}]`, 'localPeername', localPeername, localPeername2); + this.changeState({ state: 'inited', localPeername, localPeername2 }); }) .catch((errcode) => { console.error(`[${this.id}]`, 'resetP2P error', errcode); diff --git a/demo/miniprogram/components/iot-p2p-control/control.wxml b/demo/miniprogram/components/iot-p2p-control/control.wxml index 6e5d106..3c9099c 100644 --- a/demo/miniprogram/components/iot-p2p-control/control.wxml +++ b/demo/miniprogram/components/iot-p2p-control/control.wxml @@ -15,6 +15,7 @@ uuid: {{uuid}} moduleState: {{state}} localPeername: {{localPeername}} + localPeername2: {{localPeername2}} - \ No newline at end of file + diff --git a/demo/miniprogram/components/iot-p2p-input/input.js b/demo/miniprogram/components/iot-p2p-input/input.js index e41b671..3aae807 100644 --- a/demo/miniprogram/components/iot-p2p-input/input.js +++ b/demo/miniprogram/components/iot-p2p-input/input.js @@ -7,6 +7,8 @@ const { XP2PVersion } = xp2pManager; const { totalData } = config; +const isDevTools = wx.getSystemInfoSync().platform === 'devtools'; + Component({ behaviors: ['wx://component-export'], properties: { @@ -17,33 +19,126 @@ Component({ }, data: { // 这是onLoad时就固定的 - mode: '', + p2pMode: '', + cfgTargetId: '', - // 这些是不同的流,注意改变输入值不应该改变已经启动的p2p服务 - inputTargetId: '', + // 场景 + scene: 'live', + sceneList: [ + { + value: 'live', + text: '直播', + checked: true, + }, + { + value: 'playback', + text: '回放', + checked: false, + }, + ], // 1v1用 - inputProductId: '', - inputDeviceName: '', - inputXp2pInfo: '', - inputLiveParams: '', - inputPlaybackParams: '', - inputLiveStreamDomain: '', - needCheckStreamChecked: false, - needPusherChecked: false, - needDuplexChecked: false, + simpleInputs: [ + { + field: 'productId', + text: 'productId', + value: '', + }, + { + field: 'deviceName', + text: 'deviceName', + value: '', + }, + { + field: 'xp2pInfo', + text: 'xp2pInfo', + value: '', + }, + { + field: 'liveParams', + text: 'liveParams', + value: '', + scene: 'live', + }, + { + field: 'liveMjpgParams', + text: 'liveMjpgParams', + value: '', + scene: 'live', + }, + { + field: 'playbackParams', + text: 'playbackParams', + value: '', + scene: 'playback', + }, + { + field: 'playbackMjpgParams', + text: 'playbackMjpgParams', + value: '', + scene: 'playback', + }, + { + field: 'liveStreamDomain', + text: '1v1转1vn server拉流域名(填入开启1v1转1vn)', + value: '', + placeholder: '和`播放前先检查能否拉流`不兼容', + scene: 'live', + }, + ], + simpleChecks: [ + { + field: 'needCheckStream', + text: '播放前先检查能否拉流', + checked: false, + }, + { + field: 'needMjpg', + text: '播放图片流', + checked: false, + }, + { + field: 'playerRTC', + text: '播放使用RTC模式', + checked: false, + }, + ], + intercomType: 'Recorder', + intercomTypeList: [ + { + value: 'Recorder', + text: 'Recorder', + desc: 'RecorderManager采集,PCM编码', + checked: true, + }, + { + value: 'Pusher', + text: 'Pusher', + desc: 'LivePusher采集,AAC编码,支持回音消除', + checked: false, + }, + // { + // value: 'DuplexVideo', + // text: '双向音视频(实验中)', + // desc: 'LivePusher采集,视频H.264,音频AAC', + // checked: false, + // }, + ], // 1v多用 inputUrl: '', - inputCodeUrl: '', - needCode: false, - // 调试用 - onlyp2pChecked: wx.getSystemInfoSync().platform === 'devtools', // 开发者工具里不支持 live-player 和 TCPServer,默认勾选 onlyp2p + // 调试用,开发者工具里不支持 live-player 和 TCPServer,默认只拉数据不播放 + isDevTools, + playStreamChecked: { + flv: !isDevTools, + mjpg: !isDevTools, + }, // 这些是p2p状态 targetId: '', flvUrl: '', + mjpgFile: '', }, lifetimes: { created() { @@ -62,25 +157,36 @@ Component({ } console.log(`[${this.id}]`, 'setData from cfg data', data); + // 基础字段 + const { simpleInputs } = this.data; + simpleInputs.forEach((item) => { + item.value = typeof data[item.field] === 'string' ? data[item.field] : ''; + }); + + // 小程序里可以调整的字段 + const options = data.options || {}; + const { simpleChecks } = this.data; + simpleChecks.forEach((item) => { + item.checked = typeof options[item.field] === 'boolean' ? options[item.field] : false; + }); + const intercomType = options.intercomType || 'Recorder'; + const { intercomTypeList } = this.data; + intercomTypeList.forEach((item) => { + item.checked = item.value === intercomType; + }); + + // setData this.setData( { - mode: data.mode, - inputTargetId: data.targetId || '', + p2pMode: data.p2pMode, + cfgTargetId: data.targetId || '', // 1v1用 - inputProductId: data.productId || '', - inputDeviceName: data.deviceName || '', - inputXp2pInfo: data.xp2pInfo || data.peername || '', - inputLiveParams: data.liveParams || 'action=live&channel=0&quality=super', - inputPlaybackParams: data.playbackParams || 'action=playback&channel=0', - inputLiveStreamDomain: data.liveStreamDomain || '', - needCheckStreamChecked: - (!data.liveStreamDomain && typeof data.needCheckStream === 'boolean') ? data.needCheckStream : false, - needPusherChecked: typeof data.needPusher === 'boolean' ? data.needPusher : false, - needDuplexChecked: typeof data.needDuplex === 'boolean' ? data.needDuplex : false, + simpleInputs, + simpleChecks, + intercomType, + intercomTypeList, // 1v多用 inputUrl: data.flvUrl || '', - inputCodeUrl: data.codeUrl || '', - needCode: /^http:/.test(data.flvUrl), }, () => { console.log(`[${this.id}]`, 'now data', this.data); @@ -106,185 +212,190 @@ Component({ icon: 'none', }); }, - inputP2PTargetId(e) { - this.setData({ - inputTargetId: e.detail.value, - }); - }, - inputIPCProductId(e) { - this.setData({ - inputProductId: e.detail.value, - }); - }, - inputIPCDeviceName(e) { - this.setData({ - inputDeviceName: e.detail.value, - }); - }, - inputIPCXp2pInfo(e) { - this.setData({ - inputXp2pInfo: e.detail.value, - }); - }, - inputIPCLiveParams(e) { - this.setData({ - inputLiveParams: e.detail.value, + // 1v1用 + changeSceneRadio(e) { + const { sceneList } = this.data; + sceneList.forEach((item) => { + item.checked = item.value === e.detail.value; }); - }, - inputIPCPlaybackParams(e) { this.setData({ - inputPlaybackParams: e.detail.value, + scene: e.detail.value, + sceneList, }); }, - inputIPCLiveStreamDomain(e) { - this.resolveConflict({ fieldName: 'inputLiveStreamDomain', value: e.detail.value }); + inputSimpleInput(e) { + const { index } = e.currentTarget.dataset; + const item = this.data.simpleInputs[index]; + item.value = e.detail.value; this.setData({ - inputLiveStreamDomain: e.detail.value, + simpleInputs: this.data.simpleInputs, }); }, - switchNeedCheckStream(e) { - this.resolveConflict({ fieldName: 'needCheckStreamChecked', value: e.detail.value }); + switchSimpleCheck(e) { + const { index } = e.currentTarget.dataset; + const item = this.data.simpleChecks[index]; + item.checked = e.detail.value; this.setData({ - needCheckStreamChecked: e.detail.value, + simpleChecks: this.data.simpleChecks, }); }, - switchNeedPusher(e) { - this.setData({ - needPusherChecked: e.detail.value, + changeIntercomTypeRadio(e) { + const { intercomTypeList } = this.data; + intercomTypeList.forEach((item) => { + item.checked = item.value === e.detail.value; }); - }, - switchNeedDuplex(e) { this.setData({ - needDuplexChecked: e.detail.value, - }); - }, - inputServerCodeUrl(e) { - this.setData({ - inputCodeUrl: e.detail.value, + intercomType: e.detail.value, + intercomTypeList, }); }, + // 1v多用 inputStreamUrl(e) { this.setData({ inputUrl: e.detail.value, }); }, - switchOnlyP2P(e) { + // 调试用 + switchPlayStream(e) { + const { playStreamChecked } = this.data; + playStreamChecked[e.currentTarget.dataset.stream] = e.detail.value; this.setData({ - onlyp2pChecked: e.detail.value, + playStreamChecked, }); }, - resolveConflict({fieldName, value}) { - // 互斥的两个配置 - // 当连接数>=最大连接数的时候, 此时checkstream结果为不能播放, 但是1v1转向1vn却可以播放 - if (fieldName === 'inputLiveStreamDomain' && value) { - this.setData({ - needCheckStreamChecked: false, - }); - } - if (fieldName === 'needCheckStreamChecked' && value) { - this.setData({ - inputLiveStreamDomain: '', - }); - } - }, - getStreamData(type) { - if (!this.data.inputTargetId) { - this.showToast('please input targetId'); + getStreamData(sceneType, inputValues, options) { + if (!this.data.cfgTargetId) { + this.showToast('no targetId'); return; } - if (this.data.mode === 'ipc') { - if (!this.data.inputXp2pInfo) { - this.showToast('please input xp2pInfo'); + if (this.data.p2pMode === 'ipc') { + if (!inputValues.productId) { + this.showToast('please input productId'); return; } - if (type === 'live' && !this.data.inputLiveParams) { - this.showToast('please input live params'); + if (!inputValues.deviceName) { + this.showToast('please input deviceName'); return; } - if (type === 'playback' && !this.data.inputPlaybackParams) { - this.showToast('please input playback params'); + if (!inputValues.xp2pInfo) { + this.showToast('please input xp2pInfo'); return; } + if (sceneType === 'live') { + if (!inputValues.liveParams) { + this.showToast('please input live params'); + return; + } + if (options.needMjpg && !inputValues.liveMjpgParams) { + this.showToast('please input live mjpg params'); + return; + } + if (options.needMjpg && inputValues.liveStreamDomain) { + this.showToast('图片流不支持`1v1转1vn`'); + return; + } + if (inputValues.liveStreamDomain && options.needCheckStream) { + this.showToast('开启`1v1转1vn`时需取消`播放前先检查能否拉流`'); + return; + } + } + if (sceneType === 'playback') { + if (!inputValues.playbackParams) { + this.showToast('please input playback params'); + return; + } + if (options.needMjpg && !inputValues.playbackMjpgParams) { + this.showToast('please input playback mjpg params'); + return; + } + } } else { - if (!this.data.inputUrl) { - this.showToast('please input url'); + const supportHttps = compareVersion(XP2PVersion, '1.1.0')>= 0; + if (!supportHttps) { + this.showToast('please update xp2p plugin'); return; } - const supportHttps = compareVersion(XP2PVersion, '1.1.0')>= 0; - if (supportHttps && !/^https:/.test(this.data.inputUrl)) { - this.showToast('only support https url'); + if (!this.data.inputUrl) { + this.showToast('please input stream url'); return; } - if (!supportHttps && !/^http:/.test(this.data.inputUrl)) { - this.showToast('only support http url'); + if (!/^https:/.test(this.data.inputUrl)) { + this.showToast('only support https url'); return; } } let flvUrl = ''; - if (this.data.mode === 'ipc') { + let mjpgFile = ''; + if (this.data.p2pMode === 'ipc') { let flvParams = ''; - if (type === 'live') { - flvParams = this.data.inputLiveParams; - } else if (type === 'playback') { - flvParams = this.data.inputPlaybackParams; + let mjpgParams = ''; + if (sceneType === 'live') { + flvParams = inputValues.liveParams; + mjpgParams = inputValues.liveMjpgParams; + } else if (sceneType === 'playback') { + flvParams = inputValues.playbackParams; + mjpgParams = inputValues.playbackMjpgParams; } flvUrl = `http://XP2P_INFO.xnet/ipc.p2p.com/ipc.flv?${flvParams}`; + mjpgFile = `ipc.flv?${mjpgParams}`; } else { flvUrl = this.data.inputUrl; } return { - targetId: this.data.inputTargetId, + targetId: this.data.cfgTargetId, + productId: inputValues.productId, + deviceName: inputValues.deviceName, + xp2pInfo: adjustXp2pInfo(inputValues.xp2pInfo), // 兼容直接填 peername 的情况 + liveStreamDomain: inputValues.liveStreamDomain, flvUrl, - streamExInfo: { - productId: this.data.inputProductId, - deviceName: this.data.inputDeviceName, - xp2pInfo: adjustXp2pInfo(this.data.inputXp2pInfo), // 兼容直接填 peername 的情况 - liveStreamDomain: this.data.inputLiveStreamDomain, - codeUrl: this.data.inputCodeUrl, - }, + mjpgFile, }; }, - startPlayer(e) { - const streamData = this.getStreamData(e.currentTarget.dataset.type || 'live'); + startPlayer() { + const inputValues = {}; + this.data.simpleInputs.forEach(({ field, value }) => { + inputValues[field] = value; + }); + const options = { + intercomType: this.data.intercomType, // 对讲方式 + }; + this.data.simpleChecks.forEach((item) => { + options[item.field] = item.checked; + }); + const onlyp2pMap = {}; + for (const stream in this.data.playStreamChecked) { + onlyp2pMap[stream] = !this.data.playStreamChecked[stream]; + } + + const sceneType = this.data.scene || 'live'; + const streamData = this.getStreamData(sceneType, inputValues, options); if (!streamData) { return; } - const options = { - needCheckStream: this.data.needCheckStreamChecked, - needPusher: this.data.needPusherChecked, - needDuplex: this.data.needDuplexChecked, - onlyp2p: this.data.onlyp2pChecked, - }; - - if (this.data.mode === 'ipc') { + if (this.data.p2pMode === 'ipc') { // 注意字段和totalData的里一致 const recentIPC = { - mode: 'ipc', + p2pMode: 'ipc', targetId: 'recentIPC', - productId: this.data.inputProductId, - deviceName: this.data.inputDeviceName, - xp2pInfo: this.data.inputXp2pInfo, - liveParams: this.data.inputLiveParams, - playbackParams: this.data.inputPlaybackParams, - liveStreamDomain: this.data.inputLiveStreamDomain, - ...options, + ...inputValues, + options, }; totalData.recentIPC = recentIPC; wx.setStorageSync('recentIPC', recentIPC); } - console.log(`[${this.id}]`, 'startPlayer', streamData, options); + console.log(`[${this.id}]`, 'startPlayer', this.data.p2pMode, sceneType, streamData, options); this.setData(streamData); this.triggerEvent('startPlayer', { - mode: this.data.mode, - targetId: streamData.targetId, - flvUrl: streamData.flvUrl, - ...streamData.streamExInfo, - ...options, + p2pMode: this.data.p2pMode, + sceneType, + ...streamData, + options, + onlyp2pMap, }); }, }, diff --git a/demo/miniprogram/components/iot-p2p-input/input.wxml b/demo/miniprogram/components/iot-p2p-input/input.wxml index f0ba040..72ff4e3 100644 --- a/demo/miniprogram/components/iot-p2p-input/input.wxml +++ b/demo/miniprogram/components/iot-p2p-input/input.wxml @@ -1,77 +1,53 @@ - - 模式: {{mode}} - - - - - productId: - - - - - - - - deviceName: - - - - - - - - xp2pInfo: - - - - - - - - liveParams: - - - - - - - - playbackParams: - - - - - - - - 1v1转1vn server拉流域名(填入开启1v1转1vn): + + + + + {{item.text}}: - + - - - - - + + - - + + 对讲方式: + + + - + + + + P2P模式: 1v多 + + streamUrl: @@ -80,29 +56,21 @@ - - codeUrl: - - - - - - 调试用 - + + + + - - diff --git a/demo/miniprogram/components/iot-p2p-input/input.wxss b/demo/miniprogram/components/iot-p2p-input/input.wxss index 4b9e73e..6255d4f 100644 --- a/demo/miniprogram/components/iot-p2p-input/input.wxss +++ b/demo/miniprogram/components/iot-p2p-input/input.wxss @@ -5,9 +5,24 @@ position: relative; } +.scene-label { + margin-right: 20rpx; +} + .input-item { margin-bottom: 10rpx; } + +.intercom-type-item { + margin-bottom: 10rpx; +} +.intercom-type-item-radio { + float: left; +} +.intercom-type-item-text { + padding-left: 60rpx; +} + .primary-button { margin-bottom: 30rpx; } \ No newline at end of file diff --git a/demo/miniprogram/components/iot-p2p-mjpg-player/player.js b/demo/miniprogram/components/iot-p2p-mjpg-player/player.js new file mode 100644 index 0000000..e939431 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-mjpg-player/player.js @@ -0,0 +1,662 @@ +import { getParamValue, snapshotAndSave } from '../../utils'; +import { getXp2pManager } from '../../lib/xp2pManager'; +import { getRecordManager } from '../../lib/recordManager'; +import { StreamStateEnum, isStreamPlaying, httpStatusErrorMsgMap } from '../iot-p2p-common-player/common'; + +const xp2pManager = getXp2pManager(); +const { XP2PEventEnum, XP2PNotify_SubType } = xp2pManager; + +const recordManager = getRecordManager('mjpgs'); +const snapshotManager = getRecordManager('snapshots'); + +const MjpgPlayerStateEnum = { + MjpgPlayerIdle: 'MjpgPlayerIdle', + MjpgPlayerPreparing: 'MjpgPlayerPreparing', + MjpgPlayerReady: 'MjpgPlayerReady', + MjpgPlayerError: 'MjpgPlayerError', + MjpgImageError: 'MjpgImageError', + LocalServerError: 'LocalServerError', +}; + +const totalMsgMap = { + [MjpgPlayerStateEnum.MjpgPlayerPreparing]: '正在创建图片流播放器...', + [MjpgPlayerStateEnum.MjpgPlayerReady]: '创建图片流播放器成功', + [MjpgPlayerStateEnum.MjpgPlayerError]: '图片流播放器错误', + [MjpgPlayerStateEnum.MjpgImageError]: '播放图片流失败', + [MjpgPlayerStateEnum.LocalServerError]: '本地HttpServer错误', +}; + +let playerSeq = 0; + +Component({ + behaviors: ['wx://component-export'], + properties: { + targetId: { + type: String, + value: '', + }, + playerClass: { + type: String, + value: '', + }, + productId: { + type: String, + value: '', + }, + deviceName: { + type: String, + value: '', + }, + mainStreamType: { + type: String, + value: '', + }, + mainStreamState: { + type: String, + value: '', + }, + mainStreamErrMsg: { + type: String, + value: '', + }, + mjpgFile: { + type: String, + value: '', + }, + showControlRightBtns: { + type: Boolean, + value: true, + }, + // TODO 透传 image 的属性 + // 以下仅供调试,正式组件不需要 + onlyp2p: { + type: Boolean, + value: false, + }, + }, + data: { + innerId: '', + xp2pVersion: xp2pManager.XP2PVersion, + p2pPlayerVersion: xp2pManager.P2PPlayerVersion, + + // 这是attached时就固定的 + needPlayer: false, + + flvFilename: '', + flvParams: '', + streamType: '', + + // player状态 + hasPlayer: false, + playerState: MjpgPlayerStateEnum.MjpgPlayerIdle, + playerComp: null, + playerCtx: null, + playerMsg: '', + + // 主流状态,需要和主流的播放同步 + isMainStreamPlaying: false, + + // 自己的播放状态 + isPlaying: false, + playResult: '', + imgInfoStr: '', + + // debug用 + showDebugInfo: false, + isRecording: false, + }, + observers: { + mainStreamState(val) { + // console.log(`[${this.data.innerId}]`, 'mainStreamState changed', val); + const isMainStreamPlaying = isStreamPlaying(val); + if (isMainStreamPlaying === this.data.isMainStreamPlaying) { + return; + } + this.changeState({ isMainStreamPlaying }); + }, + isMainStreamPlaying(val) { + console.log(`[${this.data.innerId}]`, 'isMainStreamPlaying changed', val); + if (val) { + console.log(`[${this.data.innerId}]`, 'trigger play, reason: mainStream playing'); + this.play(); + } else { + console.log(`[${this.data.innerId}]`, 'trigger stop, reason: mainStream not playing'); + this.stop(); + } + }, + isPlaying(val) { + console.log(`[${this.data.innerId}]`, 'isPlaying changed', val); + }, + mainStreamErrMsg(val) { + console.log(`[${this.data.innerId}]`, 'mainStreamErrMsg changed', val); + if (this.properties.targetId && this.properties.mjpgFile && !this.data.isMainStreamPlaying) { + this.setData({ playerMsg: this.properties.mainStreamErrMsg || '加载中...' }); + } + }, + }, + lifetimes: { + created() { + // 在组件实例刚刚被创建时执行 + playerSeq++; + this.setData({ innerId: `mjpg-player-${playerSeq}` }); + console.log(`[${this.data.innerId}]`, '==== created'); + + // 渲染无关,不放在data里,以免影响性能 + this.userData = { + imgInfo: null, + fileObj: null, + }; + }, + attached() { + // 在组件实例进入页面节点树时执行 + console.log(`[${this.data.innerId}]`, '==== attached', this.id, this.properties); + + const [flvFilename = '', flvParams = ''] = this.properties.mjpgFile.split('?'); + const streamType = flvFilename ? getParamValue(flvParams, 'action') : ''; + const onlyp2p = this.properties.onlyp2p || false; + const needPlayer = !onlyp2p; + const hasPlayer = needPlayer && this.properties.targetId && this.properties.mjpgFile && streamType; + this.setData({ + flvFilename, + flvParams, + streamType, + needPlayer, + hasPlayer, + }); + + this.createPlayer(); + }, + detached() { + // 在组件实例被从页面节点树移除时执行 + console.log(`[${this.data.innerId}]`, '==== detached'); + this.stop(); + console.log(`[${this.data.innerId}]`, '==== detached end'); + }, + }, + export() { + return { + play: this.play.bind(this), + stop: this.stop.bind(this), + snapshot: this.snapshot.bind(this), + snapshotAndSave: this.snapshotAndSave.bind(this), + }; + }, + methods: { + getPlayerMessage(overrideData) { + if (!this.properties.targetId) { + return 'targetId为空'; + } + + if (!this.properties.mjpgFile) { + return 'mjpgFile为空'; + } + + const realData = { + playerState: this.data.playerState, + isMainStreamPlaying: this.data.isMainStreamPlaying, + isPlaying: this.data.isPlaying, + playResult: this.data.playResult, + ...overrideData, + }; + + if (!realData.isMainStreamPlaying) { + return this.properties.mainStreamErrMsg || '加载中...'; // 'mainStream未播放'; + } + + let msg = ''; + if (realData.playerState === MjpgPlayerStateEnum.MjpgPlayerReady) { + if (realData.isPlaying) { + msg = realData.playResult === 'success' ? '' : '加载中...'; + } else { + msg = realData.playResult === 'error' ? '播放图片流失败' : ''; + } + } else { + msg = totalMsgMap[realData.playerState]; + } + return msg; + }, + // 包一层,方便更新 playerMsg + changeState(newData, callback) { + const oldPlayerState = this.data.playerState; + let playerDetail; + if (newData.hasPlayer === false) { + playerDetail = { + playerComp: null, + playerCtx: null, + }; + } + this.setData({ + ...newData, + ...playerDetail, + playerMsg: this.getPlayerMessage(newData), + }, callback); + if (newData.playerState && newData.playerState !== oldPlayerState) { + this.triggerEvent('playerStateChange', { + playerState: newData.playerState, + }); + } + }, + createPlayer() { + console.log(`[${this.data.innerId}]`, 'createMjpgPlayer', Date.now()); + if (this.data.playerState !== MjpgPlayerStateEnum.MjpgPlayerIdle) { + console.error(`[${this.data.innerId}]`, 'can not createMjpgPlayer in playerState', this.data.playerState); + return; + } + + this.changeState({ + playerState: MjpgPlayerStateEnum.MjpgPlayerPreparing, + }); + }, + onPlayerReady({ detail }) { + console.log(`[${this.data.innerId}]`, '==== onPlayerReady', detail); + this.changeState({ + playerState: MjpgPlayerStateEnum.MjpgPlayerReady, + playerComp: detail.playerExport, + playerCtx: detail.mjpgPlayerContext, + }); + + if (this.data.isMainStreamPlaying) { + console.log(`[${this.data.innerId}]`, 'trigger play, reason: player ready'); + this.play(); + } + }, + onPlayerStartPull({ detail }) { + console.log(`[${this.data.innerId}]`, '==== onPlayerStartPull', detail); + // 开始拉流 + this.startStream(); + }, + onPlayerClose({ detail }) { + console.log(`[${this.data.innerId}]`, '==== onPlayerClose', detail); + // 停止拉流 + this.stopStream(); + }, + onPlayerError({ detail }) { + console.error(`[${this.data.innerId}]`, '==== onPlayerError', detail); + // 停止拉流 + this.stopStream(); + + const code = detail?.error?.code; + let playerState = MjpgPlayerStateEnum.MjpgPlayerError; + if (code === 'WECHAT_SERVER_ERROR') { + playerState = MjpgPlayerStateEnum.LocalServerError; + this.changeState({ + hasPlayer: false, + playerState, + }); + } else { + this.changeState({ + playerState, + }); + } + this.handlePlayError(playerState, { msg: `mjpgPlayerError: ${code}` }); + }, + onImageLoad({ detail }) { + console.log(`[${this.data.innerId}]`, '==== onImageLoad', detail); + if (!this.data.isPlaying) { + return; + } + this.userData.imgInfo = detail; + this.setData({ + imgInfoStr: JSON.stringify(detail), + }); + }, + onImageError({ detail }) { + console.error(`[${this.data.innerId}]`, '==== onImageError', detail); + if (!this.data.isPlaying) { + return; + } + // 停止拉流 + this.stopStream(); + + const playerState = MjpgPlayerStateEnum.MjpgImageError; + this.changeState({ + playerState, + isPlaying: false, + playResult: 'error', + imgInfoStr: '', + }); + this.handlePlayError(playerState, { msg: 'mjpgImageError' }); + }, + resetStreamData() { + this.dataCallback = null; + this.clearStreamData(); + this.changeState({ + isPlaying: false, + }); + }, + clearStreamData() { + this.userData.imgInfo = null; + this.changeState({ + playResult: '', + imgInfoStr: '', + }); + }, + play({ success, fail, complete } = {}) { + if (!this.data.playerCtx) { + console.log(`[${this.data.innerId}]`, 'call play but mjpg player not ready'); + fail && fail({ errMsg: 'mjpg player not ready' }); + complete && complete(); + return; + } + + this.clearStreamData(); + + this.data.playerCtx.play({ success, fail, complete }); + }, + stop({ success, fail, complete } = {}) { + if (!this.data.playerCtx) { + console.log(`[${this.data.innerId}]`, 'call play but mjpg player not ready'); + fail && fail({ errMsg: 'mjpg player not ready' }); + complete && complete(); + return; + } + + this.clearStreamData(); + + this.data.playerCtx.stop({ success, fail, complete }); + }, + startStream() { + console.log(`[${this.data.innerId}]`, 'startStream', this.properties.targetId); + if (!this.data.isMainStreamPlaying) { + console.log(`[${this.data.innerId}]`, 'can not start stream when main stream not playing'); + return; + } + if (this.data.isPlaying) { + console.log(`[${this.data.innerId}]`, 'already playing'); + return; + } + + const { targetId } = this.properties; + const msgCallback = (event, subtype, detail) => { + this.onP2PMessage(targetId, event, subtype, detail, { isStream: true }); + }; + + const { playerComp } = this.data; + let chunkCount = 0; + const dataCallback = (data) => { + if (!data || !data.byteLength) { + return; + } + + chunkCount++; + if (this.userData) { + this.userData.chunkCount = chunkCount; + } + if (chunkCount === 1) { + console.log(`[${this.data.innerId}]`, '==== firstChunk', data.byteLength); + this.changeState({ + playResult: 'success', + }); + } + + if (this.userData?.fileObj) { + // 写录像文件 + const writeLen = recordManager.writeRecordFile(this.userData.fileObj, data); + if (writeLen < 0) { + // 写入失败,可能是超过限制了 + this.stopRecording(); + } + } + + playerComp.addChunk(data); + }; + + this.clearStreamData(); + this.changeState({ + isPlaying: true, + }); + + xp2pManager + .startStream(this.properties.targetId, { + flv: { + filename: this.data.flvFilename, + params: this.data.flvParams, + }, + msgCallback, + dataCallback, + }) + .then((res) => { + if (!this.data.isPlaying) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, '==== startStream res', res); + if (res === 0) { + this.dataCallback = dataCallback; + } else { + this.resetStreamData(); + this.stop(); + this.handlePlayError(StreamStateEnum.StreamStartError, { msg: `startStream res ${res}` }); + } + }) + .catch((res) => { + if (!this.data.isPlaying) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, '==== startStream error', res); + this.resetStreamData(); + this.stop(); + this.handlePlayError(StreamStateEnum.StreamStartError, { msg: `startStream err ${errcode}` }); + }); + }, + stopStream() { + console.log(`[${this.data.innerId}]`, 'stopStream', this.properties.targetId); + + // 记下来,因为resetStreamData会把这个改成false + const needStopStream = this.data.isPlaying; + this.resetStreamData(); + + if (needStopStream) { + // 拉流中的才需要 xp2pManager.stopStream + console.log(`[${this.data.innerId}]`, 'do stopStream', this.properties.targetId, this.data.streamType); + xp2pManager.stopStream(this.properties.targetId, this.data.streamType); + } + }, + onP2PMessage(targetId, event, subtype, detail) { + if (targetId !== this.properties.targetId) { + return; + } + + if (event !== XP2PEventEnum.Notify) { + return; + } + + console.log(`[${this.data.innerId}]`, 'onP2PMessage_Notify', subtype, detail); + switch (subtype) { + case XP2PNotify_SubType.Parsed: + if (!detail || detail.status === 200) { + // 收到 headers + if (detail?.headers) { + this.data.playerComp.setHeaders(detail.headers); + } + } else { + this.resetStreamData(); + this.stop(); + const msg = httpStatusErrorMsgMap[detail.status] || `httpStatus: ${detail.status}`; + this.handlePlayError(StreamStateEnum.StreamHttpStatusError, { msg, detail }); + } + break; + } + }, + checkCanRetry() { + let errType; + let isFatalError = false; + let msg = ''; + if (this.data.playerState === MjpgPlayerStateEnum.MjpgPlayerError) { + // 初始化失败 + errType = this.data.playerState; + if (wx.getSystemInfoSync().platform === 'devtools') { + // 开发者工具里不支持 TCPServer,明确提示 + msg = '不支持在开发者工具中创建p2p-mjpg-player'; + } + isFatalError = true; + } else if (this.data.playerState === MjpgPlayerStateEnum.LocalServerError) { + // 本地server出错 + errType = this.data.playerState; + msg = '系统网络服务可能被中断,请重置本地RtmpServer'; + isFatalError = true; + } + if (isFatalError) { + // 不可恢复错误,退出重来 + this.triggerEvent('playError', { + errType, + errMsg: totalMsgMap[errType], + errDetail: { msg }, + isFatalError: true, + }); + return false; + } + return true; + }, + // 处理播放错误,detail: { msg: string } + handlePlayError(type, detail) { + if (!this.checkCanRetry()) { + return; + } + + // 能retry的才提示这个,不能retry的前面已经触发弹窗了 + this.triggerEvent('playError', { + errType: type, + errMsg: totalMsgMap[type] || '播放图片流失败', + errDetail: detail, + }); + }, + // 手动retry + onClickRetry() { + console.log(`[${this.data.innerId}]`, 'onClickRetry', this.data); + this.triggerEvent('clickRetry'); + }, + // 以下是播放器控件相关的 + snapshotAndSave() { + console.log(`[${this.data.innerId}]`, 'snapshotAndSave'); + snapshotAndSave({ + snapshot: this.snapshot.bind(this), + }); + }, + snapshot() { + console.log(`[${this.data.innerId}]`, 'snapshot'); + if (!this.data.playerCtx) { + return Promise.reject({ errMsg: 'player not ready' }); + } + if (!this.data.playerCtx.snapshot) { + return Promise.reject({ errMsg: 'player not support snapshot' }); + } + return new Promise((resolve, reject) => { + this.data.playerCtx.snapshot({ + quality: 'raw', + success: (res) => { + console.log(`[${this.data.innerId}]`, 'snapshot success', res?.data?.byteLength); + // mpeg-player 截图返回的是 ArrayBuffer,自己写file,保持对外接口一致 + const streamType = getParamValue(this.data.flvParams, 'action') || 'live-mjpg'; + const filePath = snapshotManager.prepareFile(`ipc-${this.properties.productId}-${this.properties.deviceName}-${streamType}.jpg`); + const fileSystem = wx.getFileSystemManager(); + fileSystem.writeFile({ + filePath, + data: res.data, + encoding: 'binary', + success: (res) => { + console.log(`[${this.data.innerId}]`, 'snapshot writeFile success', res); + resolve({ + tempImagePath: filePath, + }); + }, + fail: (err) => { + console.error(`[${this.data.innerId}]`, 'snapshot writeFile fail', err); + reject(err); + }, + }); + }, + fail: (err) => { + console.error(`[${this.data.innerId}]`, 'snapshot fail', err); + reject(err); + }, + }); + }); + }, + // 以下是调试面板相关的 + toggleDebugInfo() { + this.setData({ showDebugInfo: !this.data.showDebugInfo }); + }, + toggleRecording() { + if (this.data.isRecording) { + this.stopRecording(); + } else { + this.startRecording(); + } + }, + startRecording(recordFilename) { + if (this.data.isRecording || this.userData.fileObj) { + // 已经在录像 + return; + } + + // 保存录像文件要从头开始,停掉重新拉流 + if (this.data.isPlaying) { + this.stopStream(); + } + this.stop(); + + // 准备录像文件,注意要在 stopStream 之后 + let realRecordFilename = recordFilename; + if (!realRecordFilename) { + const streamType = getParamValue(this.data.flvParams, 'action') || 'live-mjpg'; + realRecordFilename = `ipc-${this.properties.productId}-${this.properties.deviceName}-${streamType}`; + } + const fileObj = recordManager.openRecordFile(realRecordFilename, 'mjpg'); + this.userData.fileObj = fileObj; + this.setData({ + isRecording: !!fileObj, + }); + console.log(`[${this.data.innerId}] record fileName ${fileObj && fileObj.fileName}`); + + // 重新play + console.log(`[${this.data.innerId}]`, 'trigger record play'); + this.play(); + }, + stopRecording() { + if (!this.data.isRecording || !this.userData.fileObj) { + // 没在录像 + return; + } + + console.log(`[${this.data.innerId}]`, `stopRecording, ${this.userData.fileObj.fileName}`); + const { fileObj } = this.userData; + this.userData.fileObj = null; + this.setData({ + isRecording: false, + }); + + const fileRes = recordManager.saveRecordFile(fileObj); + console.log(`[${this.data.innerId}]`, 'saveRecordFile res', fileRes); + + if (!fileRes) { + wx.showToast({ + title: '录像失败', + icon: 'error', + }); + return; + } + + wx.showModal({ + title: '录像已保存为本地用户文件', + showCancel: false, + }); + }, + cancelRecording() { + if (!this.data.isRecording || !this.userData.fileObj) { + // 没在录像 + return; + } + + console.log(`[${this.data.innerId}]`, `cancelRecording, ${this.userData.fileObj.fileName}`); + const { fileObj } = this.userData; + this.userData.fileObj = null; + this.setData({ + isRecording: false, + }); + + recordManager.closeRecordFile(fileObj); + }, + }, +}); diff --git a/demo/miniprogram/components/iot-p2p-mjpg-player/player.json b/demo/miniprogram/components/iot-p2p-mjpg-player/player.json new file mode 100644 index 0000000..32640e0 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-mjpg-player/player.json @@ -0,0 +1,3 @@ +{ + "component": true +} \ No newline at end of file diff --git a/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxml b/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxml new file mode 100644 index 0000000..64ba4e1 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxml @@ -0,0 +1,52 @@ + + + + + {{playerMsg}} + + + snapshot + + + + 调试信息 + + + + + plugin: xp2p {{xp2pVersion}} / p2p-player {{p2pPlayerVersion}} + + mjpgFile: + + {{mjpgFile}} + + + mainStreamType: {{mainStreamType}} + mainStreamState: {{mainStreamState}} + playerState: {{playerState}} + isPlaying: {{isPlaying}} + playResult: {{playResult}} + imgInfo: {{imgInfoStr}} + + record: + {{isRecording ? '停止录制' : '录制图片流'}} + 开始播放后才可以录制图片流 + + + + \ No newline at end of file diff --git a/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxss b/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxss new file mode 100644 index 0000000..4719c18 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxss @@ -0,0 +1,77 @@ +@import '../../common.wxss'; + +.iot-mjpg-player { + box-sizing: border-box; + position: relative; +} + +.mjpg-player-container { + box-sizing: border-box; + position: relative; + width: 100%; + height: 420rpx; + background-color: black; +} +.mjpg-player-container image { + width: 100%; + height: 100%; +} + +.mjpg-player-container .player-message { + position: absolute; + left: 0; + top: 50%; + width: 100%; + text-align: center; + color: white; +} + +.mjpg-player-container .player-controls-container.left { + position: absolute; + left: 20rpx; + bottom: 20rpx; +} +.mjpg-player-container .player-controls-container.right { + position: absolute; + right: 20rpx; + bottom: 20rpx; +} +.mjpg-player-container .player-controls-container .player-controls { + display: flex; +} +.mjpg-player-container .player-controls-container .player-controls .player-control-item { + position: inline-block; + vertical-align: bottom; + color: white; +} +.mjpg-player-container .player-controls-container.left .player-controls .player-control-item { + margin-right: 20rpx; +} +.mjpg-player-container .player-controls-container.right .player-controls .player-control-item { + margin-left: 20rpx; +} + +.mjpg-player-container .debug-info-switch-container { + position: absolute; + left: 20rpx; + bottom: 20rpx; +} +.mjpg-player-container .debug-info-switch-container .debug-info-switch { + position: inline-block; + vertical-align: bottom; + color: white; + /* opacity: 0; */ + margin-right: 20rpx; +} + +.iot-mjpg-player .debug-info-container { + position: absolute; + width: 100%; + max-height: 720rpx; + overflow: auto; + background-color: #fff; + box-sizing: border-box; + box-shadow: inset 0 -2rpx 0 0 rgba(0, 0, 0, 0.1); + z-index: 100; + padding: 20rpx; +} \ No newline at end of file diff --git a/demo/miniprogram/components/iot-p2p-player-ipc/common.js b/demo/miniprogram/components/iot-p2p-player-ipc/common.js new file mode 100644 index 0000000..dc2a9d8 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-player-ipc/common.js @@ -0,0 +1,74 @@ +/* eslint-disable camelcase, @typescript-eslint/naming-convention */ +export const commandMap = { + getLiveStatus: { + cmd: 'get_device_st', + params: { + type: 'live', + quality: 'super', + }, + }, + getVoiceStatus: { + cmd: 'get_device_st', + params: { + type: 'voice', + }, + }, + getRecordDates: { + cmd: 'get_month_record', + params: (date) => { + const year = date.getFullYear(); + let month = String(date.getMonth() + 1); + if (month.length < 2) { + month = `0${month}`; + } + return { time: `${year}${month}` }; // yyyymm + }, + dataHandler: (oriData) => { + const dates = []; + const tmpList = parseInt(oriData.video_list, 10).toString(2).split('').reverse(); + const tmpLen = tmpList.length; + for (let i = 0; i < tmpLen; i++) { + if (tmpList[i] === '1') { + dates.push(i + 1); + } + } + return dates; + }, + }, + getRecordVideos: { + cmd: 'get_record_index', + params: (date) => { + const startDate = new Date(date); + startDate.setHours(0, 0, 0, 0); + const start_time = startDate.getTime() / 1000; + const end_time = start_time + 3600 * 24 - 1; + return { start_time, end_time }; + }, + }, + getVideoList: { + cmd: 'get_file_list', + params: (date) => { + const startDate = new Date(date); + startDate.setHours(0, 0, 0, 0); + const start_time = startDate.getTime() / 1000; + const end_time = start_time + 3600 * 24 - 1; + // file_type: '0'-视频,'1'-图片 + return { start_time, end_time, file_type: '0' }; + }, + }, + getPlaybackStatus: { + cmd: 'get_device_st', + params: { + type: 'playback', + }, + }, + getPlaybackProgress: { + cmd: 'playback_progress', + }, + pausePlayback: { + cmd: 'playback_pause', + }, + resumePlayback: { + cmd: 'playback_resume', + }, +}; diff --git a/demo/miniprogram/components/iot-p2p-player-ipc/player.js b/demo/miniprogram/components/iot-p2p-player-ipc/player.js index 56c3604..22c9bdd 100644 --- a/demo/miniprogram/components/iot-p2p-player-ipc/player.js +++ b/demo/miniprogram/components/iot-p2p-player-ipc/player.js @@ -1,93 +1,99 @@ -import config from '../../config/config'; -import { getParamValue, toDateString, toTimeString, toDateTimeString } from '../../utils'; -import { getXp2pManager, Xp2pManagerErrorEnum } from '../../lib/xp2pManager'; +/* eslint-disable camelcase, @typescript-eslint/naming-convention */ +import { isDevTools, getParamValue, toDateString, toTimeString, toDateTimeString } from '../../utils'; +import { getXp2pManager } from '../../lib/xp2pManager'; import { getRecordManager } from '../../lib/recordManager'; +import { VoiceOpEnum, VoiceStateEnum } from '../iot-p2p-voice/common'; +import { commandMap } from './common'; const xp2pManager = getXp2pManager(); const downloadManager = getRecordManager('downloads'); const fileSystemManager = wx.getFileSystemManager(); -const voiceManager = getRecordManager('voices'); - -const { commandMap } = config; - -// ts才能用enum,先这么处理吧 -const VoiceTypeEnum = { - Recorder: 'Recorder', - Pusher: 'Pusher', - DuplexAudio: 'DuplexAudio', - DuplexVideo: 'DuplexVideo', -}; - -const voiceConfigMap = { - [VoiceTypeEnum.Recorder]: { needPusher: false }, - [VoiceTypeEnum.Pusher]: { needPusher: true }, - [VoiceTypeEnum.DuplexAudio]: { - needPusher: true, - isDuplex: true, - options: { urlParams: 'calltype=audio' }, +const sceneConfig = { + live: { + sections: { + quality: true, + ptz: true, + voice: true, + commands: true, + } }, - [VoiceTypeEnum.DuplexVideo]: { - needPusher: true, - isDuplex: true, - options: { urlParams: 'calltype=video' }, + playback: { + sections: { + download: true, + commands: true, + } }, }; -const VoiceStateEnum = { - checking: 'checking', // 检查权限和设备状态 - starting: 'starting', // 发起voice请求 - startingPusher: 'startingPusher', // 启动pusher - sending: 'sending', // 发送语音数据(包括等待pusher开始) - error: 'error', -}; - let ipcPlayerSeq = 0; Component({ behaviors: ['wx://component-export'], properties: { + ipcClass: { + type: String, + value: '', + }, + playerClass: { + type: String, + value: '', + }, targetId: { type: String, + value: '', + }, + sceneType: { + type: String, + value: 'live', }, flvUrl: { type: String, + value: '', + }, + mjpgFile: { + type: String, + value: '', }, productId: { type: String, + value: '', }, deviceName: { type: String, + value: '', }, xp2pInfo: { type: String, - }, - needCheckStream: { - type: Boolean, - value: false, - }, - needPusher: { - type: Boolean, - value: false, - }, - needDuplex: { - type: Boolean, - value: false, + value: '', }, liveStreamDomain: { type: String, value: '', }, + options: { + type: Object, + }, + sections: { + type: Object, + }, // 以下仅供调试,正式组件不需要 - onlyp2p: { - type: Boolean, + onlyp2pMap: { + type: Object, + value: { + flv: isDevTools, + mjpg: isDevTools, + }, }, }, data: { innerId: '', isDetached: false, + innerOptions: null, + innerSections: null, + // 这些是控制player和p2p的 playerId: 'iot-p2p-common-player', player: null, @@ -95,20 +101,23 @@ Component({ streamSuccess: false, checkFunctions: null, - // live / playback - type: '', + // 就是 flvUrl 里的 action: live / live-audio / playback / playback-audio + streamType: '', + streamState: 'StreamIdle', + + // p2p-player的错误提示,要展示给p2p-mjpg-player + playErrMsg: '', playerPaused: false, - // 语音对讲 - voiceState: '', // VoiceStateEnum - voiceType: '', // recorder / pusher - voiceFileObj: null, // pusher采集时把数据录下来,调试用 + // 这些是控制mjpgPlayer的 + mjpgPlayerId: 'iot-p2p-mjpg-player', + mjpgPlayer: null, - // 这些是控制pusher的 - pusherId: 'iot-p2p-common-pusher', - pusher: null, - pusherReady: false, + // 这些是语音对讲 + voiceCompId: 'iot-p2p-voice', + voiceComp: null, + voiceState: '', // VoiceStateEnum // 自定义信令 inputCommand: 'action=inner_define&channel=0&cmd=get_device_st&type=playback', @@ -145,6 +154,8 @@ Component({ playbackProgressStr: '', // 下载 + fileList: null, + inputDownloadFilename: '', downloadList: [], downloadFilename: '', downloadTotal: 0, @@ -161,16 +172,39 @@ Component({ // 在组件实例进入页面节点树时执行 console.log(`[${this.data.innerId}]`, '==== attached', this.id, this.properties); - const type = getParamValue(this.properties.flvUrl, 'action') || 'live'; - console.log(`[${this.data.innerId}]`, 'type', type); + const innerOptions = { + needCheckStream: false, + needMjpg: !!this.properties.mjpgFile, + playerRTC: !this.properties.mjpgFile, // 图片流的默认live,非图片流的默认RTC(RTC时延更低,但是ios只支持16k以上) + playerShowControlRightBtns: true, + intercomType: 'Recorder', + ...this.properties.options, + }; + console.log(`[${this.data.innerId}]`, 'innerOptions', innerOptions); + + const innerSections = { + ...sceneConfig[this.properties.sceneType].sections, + ...this.properties.sections, + }; + if (innerOptions.needMjpg) { + // 图片流模式不支持切换 quality + innerSections.quality = false; + } + console.log(`[${this.data.innerId}]`, 'innerSections', innerSections); + + const streamType = getParamValue(this.properties.flvUrl, 'action') || 'live'; + console.log(`[${this.data.innerId}]`, 'streamType', streamType); this.setData({ - type, + innerOptions, + innerSections, + streamType, checkFunctions: { checkIsFlvValid: this.checkIsFlvValid.bind(this), - checkCanStartStream: this.properties.needCheckStream ? this.checkCanStartStream.bind(this) : null, + checkCanStartStream: innerOptions.needCheckStream ? this.checkCanStartStream.bind(this) : null, }, }); + console.log(`[${this.data.innerId}]`, 'create player'); const player = this.selectComponent(`#${this.data.playerId}`); if (player) { this.setData({ player }); @@ -178,12 +212,23 @@ Component({ console.error(`[${this.data.innerId}]`, 'create player error', this.data.playerId); } - if (this.properties.needPusher || this.properties.needDuplex) { - const pusher = this.selectComponent(`#${this.data.pusherId}`); - if (pusher) { - this.setData({ pusher }); + if (this.data.innerOptions.needMjpg) { + console.log(`[${this.data.innerId}]`, 'create mjpgPlayer'); + const mjpgPlayer = this.selectComponent(`#${this.data.mjpgPlayerId}`); + if (mjpgPlayer) { + this.setData({ mjpgPlayer }); } else { - console.error(`[${this.data.innerId}]`, 'create pusher error', this.data.pusherId); + console.error(`[${this.data.innerId}]`, 'create mjpgPlayer error', this.data.mjpgPlayerId); + } + } + + if (this.data.innerSections.voice) { + console.log(`[${this.data.innerId}]`, 'create voiceComp'); + const voiceComp = this.selectComponent(`#${this.data.voiceCompId}`); + if (voiceComp) { + this.setData({ voiceComp }); + } else { + console.error(`[${this.data.innerId}]`, 'create voiceComp error', this.data.voiceCompId); } } }, @@ -193,8 +238,8 @@ Component({ detached() { // 在组件实例被从页面节点树移除时执行 console.log(`[${this.data.innerId}]`, '==== detached'); - this.stopAll(); this.setData({ isDetached: true }); + this.stopAll(); console.log(`[${this.data.innerId}]`, '==== detached end'); }, error() { @@ -204,14 +249,15 @@ Component({ export() { return { stopAll: this.stopAll.bind(this), + reset: this.reset.bind(this), + startVoice: this.startVoice.bind(this), + stopVoice: this.stopVoice.bind(this), + snapshot: this.snapshot.bind(this), + snapshotAndSave: this.snapshotAndSave.bind(this), }; }, methods: { - stopAll() { - if (this.data.voiceState) { - this.stopVoice(); - } - + stopControls() { if (this.data.ptzCmd || this.data.releasePTZTimer) { this.controlDevicePTZ('ptz_release_pre'); } @@ -220,15 +266,68 @@ Component({ this.stopDownload(); } - if (this.data.pusher) { - this.data.pusher.stop(); + if (this.data.voiceComp) { + this.data.voiceComp.stop(); + } + + if (this.data.mjpgPlayer) { + this.data.mjpgPlayer.stop(); } + this.clearPlaybackData(); + }, + stopAll() { + console.log(`[${this.data.innerId}]`, 'stopAll'); + this.stopControls(); + if (this.data.player) { this.data.player.stopAll(); } + }, + reset() { + console.log(`[${this.data.innerId}]`, 'reset'); + this.stopControls(); - this.clearPlaybackData(); + if (this.data.player) { + this.data.player.reset(); + } + }, + startVoice() { + console.log(`[${this.data.innerId}]`, 'startVoice in voiceState', this.data.voiceState); + if (!this.data.voiceComp) { + console.log(`[${this.data.innerId}]`, 'no voiceComp'); + return; + } + + this.data.voiceComp.start(); + }, + stopVoice() { + console.log(`[${this.data.innerId}]`, 'stopVoice in voiceState', this.data.voiceState); + if (!this.data.voiceComp) { + console.log(`[${this.data.innerId}]`, 'no voiceComp'); + return; + } + + this.data.voiceComp.stop(); + }, + snapshot() { + console.log(`[${this.data.innerId}]`, 'snapshot'); + const player = this.data.innerOptions?.needMjpg ? this.data.mjpgPlayer : this.data.player; + if (!player) { + return Promise.reject({ errMsg: 'player not ready' }); + } + + return player.snapshot(); + }, + snapshotAndSave() { + console.log(`[${this.data.innerId}]`, 'snapshotAndSave'); + const player = this.data.innerOptions?.needMjpg ? this.data.mjpgPlayer : this.data.player; + if (!player) { + this.showToast('player not ready'); + return; + } + + player.snapshotAndSave(); }, showToast(content) { !this.data.isDetached && wx.showToast({ @@ -256,7 +355,7 @@ Component({ onStreamStateChange(e) { console.log(`[${this.data.innerId}]`, 'onStreamStateChange', e.detail.streamState); const streamSuccess = e.detail.streamState === 'StreamHeaderParsed' || e.detail.streamState === 'StreamDataReceived'; - if (this.data.type === 'playback') { + if (this.properties.sceneType === 'playback') { if (!this.data.streamSuccess && streamSuccess) { // success后需要seek this.data.playbackProgressToResume && this.sendPlaybackSeekAfterSuccess(); @@ -278,43 +377,73 @@ Component({ }); } } - this.setData({ streamSuccess }); + this.setData({ streamSuccess, streamState: e.detail.streamState }); + this.passEvent(e); + }, + onPlayError(e) { + console.error(`[${this.data.innerId}]`, 'onPlayError', e.detail); + this.setData({ playErrMsg: e.detail.errMsg }); this.passEvent(e); }, // 以下是 pusher 的事件 - onPusherStateChange(e) { - console.log(`[${this.data.innerId}]`, 'onPusherStateChange', e.detail.pusherState); - const pusherReady = e.detail.pusherState === 'PusherReady'; - this.setData({ pusherReady }); - }, - onPusherStartPush(e) { - console.log(`[${this.data.innerId}]`, 'onPusherStartPush', e.detail); - // 开始发送语音数据了 - this.setData({ voiceState: VoiceStateEnum.sending }); - }, - onPusherClose(e) { - console.log(`[${this.data.innerId}]`, 'onPusherClose', e.detail); - if (!this.data.voiceState || !voiceConfigMap[this.data.voiceType].needPusher) { - return; - } - this.stopVoice(); + onVoiceStateChange(e) { + console.log(`[${this.data.innerId}]`, 'onVoiceStateChange', e.detail.voiceState); + this.setData({ voiceState: e.detail.voiceState}); + this.passEvent(e); + }, + onBeforeStartVoice(e) { + console.log(`[${this.data.innerId}]`, 'onBeforeStartVoice', e.detail.voiceOp); + if (e.detail.voiceOp === VoiceOpEnum.Pause) { + // 暂停播放,解决回音问题 + console.log(`[${this.data.innerId}]`, 'pausePlayer before start voice'); + this.pausePlayer(); + } else if (e.detail.voiceOp === VoiceOpEnum.Mute) { + // 静音,解决回音问题 + console.log(`[${this.data.innerId}]`, 'set superMuted before start voice'); + this.setData({ superMuted: true }); + } + }, + onAfterStopVoice(e) { + console.log(`[${this.data.innerId}]`, 'onAfterStopVoice', e.detail.voiceOp); + if (e.detail.voiceOp === VoiceOpEnum.Pause) { + // 要暂停播放的,对讲结束恢复播放 + console.log(`[${this.data.innerId}]`, 'resumePlayer after stop voice'); + this.resumePlayer(); + } else if (e.detail.voiceOp === VoiceOpEnum.Mute) { + // 要静音的,对讲结束恢复声音 + console.log(`[${this.data.innerId}]`, 'unset superMuted after stop voice'); + this.setData({ superMuted: false }); + } + }, + onVoiceError(e) { + console.error(`[${this.data.innerId}]`, 'onVoiceError', e.detail); + const { errMsg, errDetail } = e.detail; this.showModal({ - content: '推流结束', + content: `${errMsg || '对讲失败'}\n${(errDetail && errDetail.msg) || ''}`, // 换行在开发者工具中无效,真机正常, showCancel: false, }); }, - onPusherPushError(e) { - console.log(`[${this.data.innerId}]`, 'onPusherPushError', e.detail); - if (!this.data.voiceState || !voiceConfigMap[this.data.voiceType].needPusher) { - return; - } - this.stopVoice(); + // 以下是 mjpgPlayer 的事件 + onMjpgPlayerStateChange(e) { + console.log(`[${this.data.innerId}]`, 'onMjpgPlayerStateChange', e.detail.playerState); + }, + onMjpgPlayError(e) { + console.error(`[${this.data.innerId}]`, 'onMjpgPlayError', e.detail); const { errMsg, errDetail } = e.detail; this.showModal({ - content: `${errMsg || '推流失败'}\n${(errDetail && errDetail.msg) || ''}`, // 换行在开发者工具中无效,真机正常, + content: `${errMsg || '图片流失败'}\n${(errDetail && errDetail.msg) || ''}`, // 换行在开发者工具中无效,真机正常, showCancel: false, }); }, + onMjpgClickRetry(e) { + console.log(`[${this.data.innerId}]`, 'onMjpgClickRetry', e.detail); + if (!this.data.player) { + console.log(`[${this.data.innerId}]`, 'no player'); + return; + } + console.log(`[${this.data.innerId}]`, 'call retry'); + this.data.player.retry(); + }, // 以下是用户交互 changeQuality(e) { console.log(`[${this.data.innerId}]`, 'changeQuality'); @@ -331,21 +460,25 @@ Component({ playerPaused: false, }); - const { flv } = e.currentTarget.dataset; - const [filename, params] = flv.split('?'); - console.log(`[${this.data.innerId}]`, 'call changeFlv', filename, params); - this.data.player.changeFlv({ filename, params }); + const { quality } = e.currentTarget.dataset; + const [_filename, params] = this.properties.flvUrl.split('?'); + const otherParams = params.replace(/&?quality=[^&]*/g, ''); + const newParams = `${otherParams}&quality=${quality}`; + console.log(`[${this.data.innerId}]`, 'call changeFlv', newParams); + this.data.player.changeFlv({ params: newParams }); }, checkIsFlvValid({ filename, params = '' }) { console.log(`[${this.data.innerId}]`, 'checkIsFlvValid', filename, params); - const newType = getParamValue(params, 'action') || ''; - if (newType !== this.data.type) { - // 不改变type + const newStreamType = getParamValue(params, 'action') || ''; + if (newStreamType !== this.data.streamType) { + // 不改变streamType + console.warn(`[${this.data.innerId}]`, 'checkIsFlvValid false, streamType mismatch', this.data.streamType, newStreamType); return false; } - if (this.data.type === 'playback') { + if (this.properties.sceneType === 'playback') { const start = parseInt(getParamValue(params, 'start_time'), 10); const end = parseInt(getParamValue(params, 'end_time'), 10); + console.warn(`[${this.data.innerId}]`, 'checkIsFlvValid false, playback time invalid', params); return start> 0 && end - start>= 5; } return true; @@ -355,7 +488,7 @@ Component({ // 1v1转1v多和检查不共存 if (this.data.liveStreamDomain) { - this.properties.needCheckStream = false; + this.data.innerOptions.needCheckStream = false; } let errMsg = ''; @@ -367,7 +500,7 @@ Component({ return Promise.reject(errMsg); } - if (!this.properties.needCheckStream) { + if (!this.data.innerOptions.needCheckStream) { // 不用检查设备状态 return Promise.resolve(true); } @@ -377,7 +510,7 @@ Component({ .sendInnerCommand(this.properties.targetId, { cmd: 'get_device_st', params: { - type: this.data.type, + type: this.data.streamType, quality: getParamValue(params, 'quality') || '', }, }) @@ -488,280 +621,6 @@ Component({ this.resumePlayer(); }, - checkAuthCanStartVoice() { - console.log(`[${this.data.innerId}]`, 'checkAuthCanStartVoice'); - return new Promise((resolve, reject) => { - xp2pManager - .checkRecordAuthorize() - .then(() => { - console.log(`[${this.data.innerId}]`, 'checkRecordAuthorize success'); - resolve(); - }) - .catch((err) => { - console.log(`[${this.data.innerId}]`, 'checkRecordAuthorize err', err); - this.showToast('请授权小程序访问麦克风'); - reject(err); - }); - }); - }, - checkDeviceCanStartVoice() { - console.log(`[${this.data.innerId}]`, 'checkDeviceCanStartVoice'); - return new Promise((resolve, reject) => { - xp2pManager - .sendInnerCommand(this.properties.targetId, { - cmd: 'get_device_st', - params: { - type: 'voice', - }, - }) - .then((res) => { - let canStart = false; - let errMsg = ''; - const data = res[0]; // 返回的 res 是数组 - const status = parseInt(data && data.status, 10); // 有的设备返回的 status 是字符串,兼容一下 - console.log(`[${this.data.innerId}]`, 'checkDeviceCanStartVoice status', status); - switch (status) { - case 0: - canStart = true; - break; - case 1: - errMsg = '设备正忙,请稍后重试'; - break; - case 405: - errMsg = '当前连接人数超过限制,请稍后重试'; - break; - default: - console.error(`[${this.data.innerId}]`, 'check can start voice, unknown status', status); - } - if (canStart) { - resolve(); - } else { - this.showToast(errMsg || '获取设备状态失败'); - reject(errMsg || '获取设备状态失败'); - } - }) - .catch((errmsg) => { - console.log(`[${this.data.innerId}]`, 'checkDeviceCanStartVoice error', errmsg); - this.showToast('获取设备状态失败'); - reject('获取设备状态失败'); - }); - }); - }, - async startVoice(e) { - console.log(`[${this.data.innerId}]`, 'startVoice'); - if (!this.data.p2pReady) { - console.log(`[${this.data.innerId}]`, 'p2p not ready'); - return; - } - if (this.data.voiceState) { - console.log(`[${this.data.innerId}]`, `can not start voice in voiceState ${this.data.voiceState}`); - return; - } - - const voiceType = e.currentTarget.dataset.voiceType || VoiceTypeEnum.Recorder; - const voiceConfig = voiceConfigMap[voiceType] || {}; - if (voiceConfig.needPusher && !this.data.pusherReady) { - this.showToast('pusher not ready'); - return; - } - - // 记录对讲类型 - this.setData({ voiceType }); - - if (voiceConfig.isDuplex) { - // 是双向音视频,在demo里省略呼叫应答功能,直接发起 - this.doStartVoiceByPusher(e); - return; - } - - // 是普通语音对讲,先检查能否对讲 - this.setData({ voiceState: VoiceStateEnum.checking }); - - try { - await this.checkAuthCanStartVoice(); - } catch (err) { - if (!this.data.voiceState) { - // 已经stop了 - return; - } - console.log(`[${this.data.innerId}]`, '==== checkAuthCanStartVoice error', err); - this.stopVoice(); - return; - } - - try { - await this.checkDeviceCanStartVoice(); - } catch (err) { - if (!this.data.voiceState) { - // 已经stop了 - return; - } - console.log(`[${this.data.innerId}]`, '==== checkDeviceCanStartVoice error', err); - this.stopVoice(); - return; - } - - if (!this.data.voiceState) { - // 已经stop了 - return; - } - // 检查通过,开始对讲 - console.log(`[${this.data.innerId}]`, '==== checkCanStartVoice success'); - if (voiceConfig.needPusher) { - this.doStartVoiceByPusher(e); - } else { - this.doStartVoiceByRecorder(e); - } - }, - doStartVoiceByRecorder(e) { - // 每种采样率有对应的编码码率范围有效值,设置不合法的采样率或编码码率会导致录音失败 - // 具体参考 https://developers.weixin.qq.com/miniprogram/dev/api/media/recorder/RecorderManager.start.html - const [numberOfChannels, sampleRate, encodeBitRate] = e.currentTarget.dataset.recorderCfg - .split('-') - .map((v) => Number(v)); - const recorderOptions = { - numberOfChannels, // 录音通道数 - sampleRate, // 采样率 - encodeBitRate, // 编码码率 - }; - - console.log(`[${this.data.innerId}]`, 'do doStartVoiceByRecorder', this.properties.targetId, recorderOptions); - this.setData({ voiceState: VoiceStateEnum.starting }); - xp2pManager - .startVoice(this.properties.targetId, recorderOptions, { - onPause: (res) => { - console.log(`[${this.data.innerId}]`, 'voice onPause', res); - // 简单点,recorder暂停就停止语音对讲 - this.stopVoice(); - }, - onStop: (res) => { - console.log(`[${this.data.innerId}]`, 'voice onStop', res); - if (!res.willRestart) { - // 如果是到时间触发的,插件会自动续期,不自动restart的才需要stopVoice - this.stopVoice(); - } - }, - }) - .then((res) => { - if (!this.data.voiceState) { - // 已经stop了 - return; - } - console.log(`[${this.data.innerId}]`, 'startVoice success', res); - this.setData({ voiceState: VoiceStateEnum.sending }); - }) - .catch((errcode) => { - if (!this.data.voiceState) { - // 已经stop了 - return; - } - console.log(`[${this.data.innerId}]`, 'startVoice fail', errcode); - this.showToast(errcode === Xp2pManagerErrorEnum.NoAuth ? '请授权小程序访问麦克风' : '发起语音对讲失败'); - this.stopVoice(); - }); - }, - doStartVoiceByPusher(e) { - const { options } = voiceConfigMap[this.data.voiceType]; - - // 弄个副本,以免被修改 - const voiceOptions = { ...options }; - - const needRecord = parseInt(e.currentTarget.dataset.needRecord, 10); - - console.log(`[${this.data.innerId}]`, 'do doStartVoiceByPusher', this.properties.targetId, voiceOptions); - this.setData({ voiceState: VoiceStateEnum.starting }); - xp2pManager - .startVoiceData(this.properties.targetId, voiceOptions, { - onStop: () => { - if (!this.data.voiceState) { - // 已经stop了 - return; - } - console.log(`[${this.data.innerId}]`, 'voice onStop'); - this.stopVoice(); - }, - onComplete: () => { - if (!this.data.voiceState) { - // 已经stop了 - return; - } - console.log(`[${this.data.innerId}]`, 'voice onComplete'); - this.stopVoice(); - }, - }) - .then((writer) => { - if (!this.data.voiceState) { - // 已经stop了 - return; - } - console.log(`[${this.data.innerId}]`, 'startVoiceData success', writer); - - let writerForPusher = writer; - if (needRecord) { - // 录制,方便验证,正式版本不需要 - const voiceFileObj = voiceManager.openRecordFile(`voice-${this.properties.productId}-${this.properties.deviceName}`); - this.setData({ - voiceFileObj, - }); - writerForPusher = { - addChunk: (chunk) => { - // 收到pusher的数据 - // 写文件 - voiceManager.writeRecordFile(voiceFileObj, chunk); - // 写到xp2p语音请求里 - writer.addChunk(chunk); - }, - }; - } - - // 启动推流,这时还不能发送数据 - this.setData({ voiceState: VoiceStateEnum.startingPusher }); - this.data.pusher.start({ - writer: writerForPusher, - fail: (err) => { - console.log(`[${this.data.innerId}]`, 'voice pusher start fail', err); - this.stopVoice(); - }, - }); - }) - .catch((errcode) => { - if (!this.data.voiceState) { - // 已经stop了 - return; - } - console.log(`[${this.data.innerId}]`, 'startVoiceData fail', errcode); - this.showToast('对讲失败'); - this.stopVoice(); - }); - }, - stopVoice() { - console.log(`[${this.data.innerId}]`, 'stopVoice'); - if (!this.data.p2pReady) { - console.log(`[${this.data.innerId}]`, 'p2p not ready'); - return; - } - if (!this.data.voiceState) { - console.log(`[${this.data.innerId}]`, 'not voicing'); - return; - } - - console.log(`[${this.data.innerId}]`, 'do stopVoice', this.properties.targetId, this.data.voiceType, this.data.voiceState); - - const { voiceType, voiceState, voiceFileObj } = this.data; - this.setData({ voiceType: '', voiceState: '', voiceFileObj: null }); - if (voiceFileObj) { - voiceManager.closeRecordFile(voiceFileObj); - } - if (voiceConfigMap[voiceType].needPusher) { - // 如果是pusher,先停掉 - if (voiceState === VoiceStateEnum.sending) { - this.data.pusher.stop(); - } - } else { - // 如果是recorder,p2p模块里的stopVoice里会停 - } - xp2pManager.stopVoice(this.properties.targetId); - }, pickDate(e) { this.setData({ inputDate: e.detail.value, @@ -802,6 +661,23 @@ Component({ } }); }, + getVideoList(e) { + const date = new Date(this.data.inputDate.replace(/-/g, '/')); + if (!this.data.inputDate) { + this.showToast('please select date'); + return; + } + this.sendInnerCommand(e, date, ({ file_list = [] } = {}) => { + if (file_list.length> 0) { + // 更新 inputDownloadFilename + const item = file_list[file_list.length - 1]; + this.setData({ + fileList: file_list, + inputDownloadFilename: item.file_name, + }); + } + }); + }, clearPlaybackData() { this.data.sliderTimer && clearTimeout(this.data.sliderTimer); this.setData({ @@ -845,10 +721,9 @@ Component({ playbackDuration: (endTime - startTime) * 1000, }); - const filename = 'ipc.flv'; - const params = `action=playback&channel=0&${this.data.inputPlaybackTime}`; - console.log(`[${this.data.innerId}]`, 'call changeFlv', filename, params); - this.data.player.changeFlv({ filename, params }); + const params = `action=${this.data.streamType}&channel=0&${this.data.inputPlaybackTime}`; + console.log(`[${this.data.innerId}]`, 'call changeFlv', params); + this.data.player.changeFlv({ params }); }, stopPlayback() { console.log(`[${this.data.innerId}]`, 'stopPlayback'); @@ -863,10 +738,37 @@ Component({ this.clearPlaybackData(); - const filename = 'ipc.flv'; - const params = 'action=playback&channel=0'; - console.log(`[${this.data.innerId}]`, 'call changeFlv', filename, params); - this.data.player.changeFlv({ filename, params }); + const params = `action=${this.data.streamType}&channel=0`; + console.log(`[${this.data.innerId}]`, 'call changeFlv', params); + this.data.player.changeFlv({ params }); + }, + inputIPCDownloadFilename(e) { + this.setData({ + inputDownloadFilename: e.detail.value, + }); + }, + downloadInputFile() { + console.log(`[${this.data.innerId}]`, 'downloadInputFile'); + if (!this.data.p2pReady) { + console.log(`[${this.data.innerId}]`, 'p2p not ready'); + return; + } + if (!this.data.inputDownloadFilename) { + this.showToast('please input filename'); + return; + } + + let fileItem = this.data.fileList?.find(item => item.file_name === this.data.inputDownloadFilename); + if (!fileItem) { + // 构造一个 + fileItem = { + file_type: '0', + file_name: this.data.inputDownloadFilename, + file_size: 1, + }; + } + + this.addToDownloadList([fileItem]); }, async downloadRecord() { console.log(`[本地下载] [${this.data.innerId}]`, 'downloadRecord'); @@ -883,9 +785,11 @@ Component({ file_type: '0', }, }); - const fileList = res?.file_list; + let fileList = res?.file_list; console.log('[本地下载] fileList: ', fileList); + // file_type: '0'-视频,'1'-图片 if (Array.isArray(fileList) && fileList.some(item => item.file_type === '0')) { + fileList = fileList.filter(item => item.file_type === '0'); console.log('[本地下载] 已加入下载队列!'); wx.showToast({ title: '已加入下载队列!', @@ -901,9 +805,12 @@ Component({ return; } + this.addToDownloadList(fileList); + }, + addToDownloadList(fileList) { // 加入downloadList,这里只下载视频,file_type: '0'-视频,'1'-图片 this.setData({ - downloadList: this.data.downloadList.concat(fileList.filter(item => item.file_type === '0')), + downloadList: this.data.downloadList.concat(fileList), }); if (this.data.downloadFilename) { @@ -930,18 +837,21 @@ Component({ downloadBytes, }); - let fixedFilename = file.file_name.replace('/', '_'); + let fixedFilename = file.file_name.replace(/\//g, '_'); const pos = fixedFilename.lastIndexOf('.'); if (pos>= 0) { // 把文件大小加到文件名里方便对比 fixedFilename = `${fixedFilename.substring(0, pos)}.${file.file_size}${fixedFilename.substring(pos)}`; + } else { + fixedFilename = `${file.file_name}.${file.file_size}.mp4`; // 默认mp4 } const filePath = downloadManager.prepareFile(fixedFilename); + console.log(`[${this.data.innerId}]`, 'startSingleDownload', downloadFilename, fixedFilename, filePath); await xp2pManager.startLocalDownload( this.properties.targetId, { - urlParams: `_crypto=off&channel=0&file_name=${file.file_name}&offset=0`, + urlParams: `channel=0&file_name=${file.file_name}&offset=0`, }, { onChunkReceived: (chunk) => { @@ -1472,14 +1382,16 @@ Component({ }); }); }, - toggleVoice(e) { - if (!this.data.p2pReady) { + toggleVoice() { + console.log(`[${this.data.innerId}]`, 'toggleVoice in voiceState', this.data.voiceState); + if (!this.data.voiceComp) { + console.log(`[${this.data.innerId}]`, 'no voiceComp'); return; } - const isSendingVoice = this.data.voiceState === VoiceStateEnum.sending; - if (!isSendingVoice) { - this.startVoice(e); + if (this.data.voiceState !== VoiceStateEnum.sending) { + // waiting 期间也 startVoice,具体交给 voice 组件处理 + this.startVoice(); } else { this.stopVoice(); } diff --git a/demo/miniprogram/components/iot-p2p-player-ipc/player.wxml b/demo/miniprogram/components/iot-p2p-player-ipc/player.wxml index 1e99a2d..1fa44c2 100644 --- a/demo/miniprogram/components/iot-p2p-player-ipc/player.wxml +++ b/demo/miniprogram/components/iot-p2p-player-ipc/player.wxml @@ -1,48 +1,60 @@ - - + + + - - - 双向音视频 - - voiceType: {{voiceType}} - voiceState: {{voiceState}} - - - + - - - PTZ控制 - + @@ -58,8 +70,8 @@ - - {{voiceType+'\n'+voiceState}} + + @@ -67,183 +79,168 @@ - - - - 切换清晰度 - - - - - - - - 语音对讲 - - + - pusherReady: {{pusherReady}} - - voiceType: {{voiceType}} - voiceState: {{voiceState}} - - - - - - - - 内置信令 - - - + + + 内置信令 + + + + - - - - 当前录像 - - {{playerPlaybackTimeLocaleStr}} - ({{playbackDuration / 1000}}s) + + + 当前录像 + + {{playerPlaybackTimeLocaleStr}} + ({{playbackDuration / 1000}}s) + + + + + + + + + + {{playbackProgressStr}} + - - - - - - + + 查询录像/文件 + + + + + + 当前选择: {{inputDate}} + + + + + + + + + + - - {{playbackProgressStr}} + + 播放录像 + + + + + + + + + + + - - - 查询录像 - + + 下载文件 - - - 当前选择: {{inputDate}} - - + + + + + + + 待下载文件数:{{downloadList.length}} + 当前下载文件:{{downloadFilename}} ({{downloadTotal}}) + 当前下载进度:{{downloadBytes}} + - - - + + 内置信令 + + + - - 播放录像 + + 自定义信令 + command: - + - - - - - - - + responseType: - 待下载文件数:{{downloadList.length}} - 当前下载文件:{{downloadFilename}} ({{downloadTotal}}) - 当前下载进度:{{downloadBytes}} + + + + - - - 内置信令 - - - 自定义信令 - command: - - - - - - responseType: - - - - - - - - - - diff --git a/demo/miniprogram/components/iot-p2p-player-ipc/player.wxss b/demo/miniprogram/components/iot-p2p-player-ipc/player.wxss index bd1dc0a..69291b2 100644 --- a/demo/miniprogram/components/iot-p2p-player-ipc/player.wxss +++ b/demo/miniprogram/components/iot-p2p-player-ipc/player.wxss @@ -2,6 +2,26 @@ @import '../../input.wxss'; @import './ptz-panel.wxss'; +.iot-p2p-player-ipc { + box-sizing: border-box; + position: relative; +} + +.player-wrap { + box-sizing: border-box; + position: relative; + width: 750rpx; + height: 420rpx; +} + +.sections-wrap { + box-sizing: border-box; + position: relative; + height: calc(100vh - 420rpx); + padding: 20rpx 0; + overflow: scroll; +} + .ptz-panel-wrap { position: relative; display: flex; diff --git a/demo/miniprogram/components/iot-p2p-player-ipc/ptz-panel.wxss b/demo/miniprogram/components/iot-p2p-player-ipc/ptz-panel.wxss index 69e8efb..648d37d 100644 --- a/demo/miniprogram/components/iot-p2p-player-ipc/ptz-panel.wxss +++ b/demo/miniprogram/components/iot-p2p-player-ipc/ptz-panel.wxss @@ -2,7 +2,7 @@ position: relative; } .ptz-panel .ptz-controls { - padding: 32rpx; + padding: 0; } .ptz-panel .ptz-controls .ptz-controls-top { position: relative; @@ -117,3 +117,6 @@ .ptz-panel .ptz-controls .voice-icon.press { background: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 fill%3D%22none%22 viewBox%3D%220 0 32 32%22 class%3D%22design-iconfont%22%3E %3Cpath d%3D%22M16.3921 2C13.6857 2 11.491 4.17919 11.491 6.86709V15.4237C11.491 18.1116 13.6857 20.2908 16.3921 20.2908C19.0984 20.2908 21.2931 18.1116 21.2931 15.4237V6.86709C21.2931 4.17919 19.0984 2 16.3921 2ZM8.02028 14.2063C7.45619 14.2063 7 14.6593 7 15.2204C7 15.2913 7.00616 15.359 7.02153 15.4237H7C7 20.1614 10.5601 24.0668 15.1683 24.6648V27.1862H13.5347C12.8565 27.1862 12.3079 27.7318 12.3079 28.4038C12.3079 29.0757 12.8565 29.6213 13.5347 29.6213H19.2525C19.9306 29.6213 20.4792 29.0757 20.4792 28.4038C20.4792 27.7318 19.9306 27.1862 19.2525 27.1862H17.6189V24.6648C22.0914 24.0853 25.5684 20.3834 25.7655 15.8308C25.7779 15.766 25.7872 15.6982 25.7872 15.6273C25.7872 15.6026 25.7811 15.581 25.7811 15.5595C25.7811 15.5132 25.7872 15.4702 25.7872 15.4238H25.7655C25.6699 14.9614 25.2601 14.6131 24.7637 14.6131C24.2705 14.6131 23.8575 14.9614 23.7619 15.4238H23.7404C23.7404 19.4556 20.4484 22.7229 16.3889 22.7229C12.3294 22.7229 9.03737 19.4556 9.03737 15.4238H9.01584C9.02822 15.3591 9.03737 15.2913 9.03737 15.2204C9.04056 14.6593 8.58441 14.2063 8.02028 14.2063Z%22 fill%3D%22%230ABF5B%22%2F%3E%3C%2Fsvg%3E") center center no-repeat; } +.ptz-panel .ptz-controls .voice-icon.disabled { + opacity: 0.6; +} diff --git a/demo/miniprogram/components/iot-p2p-player-server/player.wxml b/demo/miniprogram/components/iot-p2p-player-server/player.wxml index db00788..ae3d330 100644 --- a/demo/miniprogram/components/iot-p2p-player-server/player.wxml +++ b/demo/miniprogram/components/iot-p2p-player-server/player.wxml @@ -1,8 +1,8 @@ - - + + @@ -22,11 +21,17 @@ childrenSize: {{childrenSize}} childrenStr: {{childrenStr}} parent: {{parent}} - errlog: - {{errLog}} + + peerlist: {{peerlist}} + + + errlog: + {{errLog}} + + subscribeLog: - {{subscribeLog}} + {{subscribeLog}} diff --git a/demo/miniprogram/components/iot-p2p-player-server/player.wxss b/demo/miniprogram/components/iot-p2p-player-server/player.wxss index 9626f33..6780288 100644 --- a/demo/miniprogram/components/iot-p2p-player-server/player.wxss +++ b/demo/miniprogram/components/iot-p2p-player-server/player.wxss @@ -1 +1,16 @@ @import '../../common.wxss'; + +.iot-p2p-player-server { + box-sizing: border-box; + padding-top: 420rpx; + height: calc(100vh - 50rpx); + overflow: scroll; +} + +.player-wrap { + position: absolute; + top: 0; + width: 750rpx; + height: 420rpx; + z-index: 1; +} diff --git a/demo/miniprogram/components/iot-p2p-voice/common.js b/demo/miniprogram/components/iot-p2p-voice/common.js new file mode 100644 index 0000000..d70e234 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-voice/common.js @@ -0,0 +1,49 @@ +// ts才能用enum,先这么处理吧 +export const VoiceTypeEnum = { + Recorder: 'Recorder', + Pusher: 'Pusher', + DuplexAudio: 'DuplexAudio', + DuplexVideo: 'DuplexVideo', +}; + +export const VoiceOpEnum = { + None: 'none', + Mute: 'mute', + Pause: 'pause', +}; + +export const voiceConfigMap = { + [VoiceTypeEnum.Recorder]: { + needPusher: false, + needDuplex: false, + voiceOp: VoiceOpEnum.Mute, + options: { + numberOfChannels: 1, // 录音通道数 + sampleRate: 8000, // 采样率 + encodeBitRate: 16000, // 编码码率 + }, + }, + [VoiceTypeEnum.Pusher]: { + needPusher: true, + needDuplex: false, + }, + [VoiceTypeEnum.DuplexAudio]: { + needPusher: true, + needDuplex: true, + options: { urlParams: 'calltype=audio' }, + }, + [VoiceTypeEnum.DuplexVideo]: { + needPusher: true, + needDuplex: true, + options: { urlParams: 'calltype=video' }, + }, +}; + +export const VoiceStateEnum = { + authChecking: 'authChecking', // 检查权限 + deviceChecking: 'deviceChecking', // 检查设备状态 + preparing: 'preparing', // 发起voice请求 + starting: 'starting', // 启动pusher + sending: 'sending', // 发送语音数据(包括等待pusher推流) + error: 'error', +}; diff --git a/demo/miniprogram/components/iot-p2p-voice/voice.js b/demo/miniprogram/components/iot-p2p-voice/voice.js new file mode 100644 index 0000000..e6dc820 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-voice/voice.js @@ -0,0 +1,523 @@ +import { sysInfo } from '../../utils'; +import { getXp2pManager, Xp2pManagerErrorEnum } from '../../lib/xp2pManager'; +import { getRecordManager } from '../../lib/recordManager'; +import { VoiceOpEnum, VoiceStateEnum, voiceConfigMap } from './common'; + +const xp2pManager = getXp2pManager(); + +const voiceManager = getRecordManager('voices'); + +Component({ + behaviors: ['wx://component-export'], + options: { + addGlobalClass: true, + }, + properties: { + targetId: { + type: String, + value: '', + }, + productId: { + type: String, + value: '', + }, + deviceName: { + type: String, + value: '', + }, + intercomType: { + type: String, + value: 'Recorder', + }, + p2pReady: { + type: Boolean, + value: false, + }, + }, + data: { + innerId: '', + isDetached: false, + + // 语音对讲 + needPusher: false, // attached 时根据 intercomType 设置 + needDuplex: false, // attached 时根据 intercomType 设置 + voiceState: '', // VoiceStateEnum + // voiceFileObj: null, // 移到 userData + + // 对讲时的特殊处理,recorder 默认 mute + voiceOp: VoiceOpEnum.None, // none/mute/pause,attached 时根据 intercomType 设置初始值 + + // 这些是控制pusher的 + pusherId: 'iot-p2p-common-pusher', + // pusher: null, // 移到 userData + pusherReady: false, + pusherError: null, + pusherProps: { + isRTC: true, + enableAgc: true, + enableAns: true, + highQuality: false, + }, + pusherPropChecks: [ + { + field: 'isRTC', + text: 'RTC模式(自动开启回声抑制)', + }, + { + field: 'enableAgc', + text: '自动增益(补偿音量,但会放大噪音)', + }, + { + field: 'enableAns', + text: '噪声抑制(过滤噪音,但会误伤正常声音)', + }, + { + field: 'highQuality', + text: '高音质(高-48KHz,低-16KHz)', + }, + ], + isModifyPusher: false, + }, + observers: { + voiceState(val) { + this.triggerEvent('voiceStateChange', { + voiceState: val, + }); + }, + }, + lifetimes: { + created() { + this.setData({ innerId: 'p2p-voice' }); + console.log(`[${this.data.innerId}]`, '==== created'); + + // 渲染无关,不放在data里,以免影响性能 + this.userData = { + pusher: null, + voiceFileObj: null, // pusher采集时把数据录下来,调试用 + }; + }, + attached() { + console.log(`[${this.data.innerId}]`, '==== attached', this.id, this.properties); + + const voiceConfig = voiceConfigMap[this.properties.intercomType]; + console.log(`[${this.data.innerId}]`, 'voiceConfig', this.properties.intercomType, voiceConfig); + if (!voiceConfig) { + return; + } + + this.setData({ + needPusher: voiceConfig.needPusher, + needDuplex: voiceConfig.needDuplex, + voiceOp: voiceConfig.voiceOp || VoiceOpEnum.None, + }); + if (voiceConfig.needPusher) { + this.getPusherComp(); + } + }, + detached() { + console.log(`[${this.data.innerId}]`, '==== detached'); + this.setData({ isDetached: true }); + this.stopVoice(); + console.log(`[${this.data.innerId}]`, '==== detached end'); + }, + }, + export() { + return { + start: this.startVoice.bind(this), + stop: this.stopVoice.bind(this), + }; + }, + methods: { + showToast(content) { + !this.data.isDetached && wx.showToast({ + title: content, + icon: 'none', + }); + }, + showModal(params) { + !this.data.isDetached && wx.showModal(params); + }, + getPusherComp() { + const pusher = this.selectComponent(`#${this.data.pusherId}`); + if (pusher) { + this.userData.pusher = pusher; + console.log(`[${this.data.innerId}]`, 'getPusherComp success', pusher); + } else { + console.error(`[${this.data.innerId}]`, 'getPusherComp error', this.data.pusherId); + } + }, + onPusherStateChange(e) { + console.log(`[${this.data.innerId}]`, 'onPusherStateChange', e.detail.pusherState); + const pusherReady = e.detail.pusherState === 'PusherReady'; + this.setData({ pusherReady }); + }, + onPusherStartPush(e) { + // 真正开始推流了 + console.log(`[${this.data.innerId}]`, 'onPusherStartPush', e.detail); + if (this.data.voiceState !== VoiceStateEnum.sending) { + console.warn(`[${this.data.innerId}]`, 'onPusherStartPush in voiceState', this.data.voiceState); + } + }, + onPusherClose(e) { + console.log(`[${this.data.innerId}]`, 'onPusherClose', e.detail); + if (!this.data.voiceState || !this.data.needPusher) { + return; + } + this.stopVoice(); + this.showModal({ + content: '推流结束', + showCancel: false, + }); + }, + onPusherPushError(e) { + console.error(`[${this.data.innerId}]`, 'onPusherPushError', e.detail); + if (this.data.voiceState && this.data.needPusher) { + this.stopVoice(); + } + const { errType, errMsg, errDetail } = e.detail; + if (errType === 'PusherError' || errType === 'LivePusherError') { + this.setData({ pusherReady: false, pusherError: e.detail }); + } + this.showModal({ + content: `${errMsg || '推流失败'}\n${(errDetail && errDetail.msg) || ''}`, // 换行在开发者工具中无效,真机正常, + showCancel: false, + }); + }, + checkAuthCanStartVoice() { + console.log(`[${this.data.innerId}]`, 'checkAuthCanStartVoice'); + return new Promise((resolve, reject) => { + xp2pManager + .checkRecordAuthorize() + .then(() => { + console.log(`[${this.data.innerId}]`, 'checkRecordAuthorize success'); + resolve(); + }) + .catch((err) => { + console.log(`[${this.data.innerId}]`, 'checkRecordAuthorize err', err); + this.showToast('请授权小程序访问麦克风'); + reject(err); + }); + }); + }, + checkDeviceCanStartVoice() { + console.log(`[${this.data.innerId}]`, 'checkDeviceCanStartVoice'); + return new Promise((resolve, reject) => { + xp2pManager + .sendInnerCommand(this.properties.targetId, { + cmd: 'get_device_st', + params: { + type: 'voice', + }, + }) + .then((res) => { + let canStart = false; + let errMsg = ''; + const data = res[0]; // 返回的 res 是数组 + const status = parseInt(data && data.status, 10); // 有的设备返回的 status 是字符串,兼容一下 + console.log(`[${this.data.innerId}]`, 'checkDeviceCanStartVoice status', status, res); + switch (status) { + case 0: + canStart = true; + break; + case 1: + errMsg = '设备正忙,请稍后重试'; + break; + case 405: + errMsg = '当前连接人数超过限制,请稍后重试'; + break; + default: + console.error(`[${this.data.innerId}]`, 'check can start voice, unknown status', status); + } + if (canStart) { + resolve(); + } else { + this.showToast(errMsg || '获取设备状态失败'); + reject(errMsg || '获取设备状态失败'); + } + }) + .catch((errmsg) => { + console.log(`[${this.data.innerId}]`, 'checkDeviceCanStartVoice error', errmsg); + this.showToast('获取设备状态失败'); + reject('获取设备状态失败'); + }); + }); + }, + async startVoice(e) { + console.log(`[${this.data.innerId}]`, 'startVoice'); + if (this.data.voiceState) { + console.log(`[${this.data.innerId}]`, `can not start voice in voiceState ${this.data.voiceState}`); + return; + } + + if (this.data.needPusher && !this.data.pusherReady) { + if (this.data.pusherError) { + const { errMsg, errDetail } = this.data.pusherError; + this.showModal({ + content: `${errMsg || '推流失败'}\n${(errDetail && errDetail.msg) || ''}`, // 换行在开发者工具中无效,真机正常, + showCancel: false, + }); + } else { + this.showToast('pusher not ready'); + } + return; + } + + if (this.data.needDuplex) { + // 是双向音视频,在demo里省略呼叫应答功能,直接发起 + this.triggerEvent('beforeStartVoice', { voiceOp: this.data.voiceOp }); + this.doStartVoiceByPusher(e); + return; + } + + // 是普通语音对讲,先检查能否对讲 + try { + this.setData({ voiceState: VoiceStateEnum.authChecking }); + await this.checkAuthCanStartVoice(); + } catch (err) { + if (!this.data.voiceState) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, '==== checkAuthCanStartVoice error', err); + this.stopVoice(); + return; + } + + try { + this.setData({ voiceState: VoiceStateEnum.deviceChecking }); + await this.checkDeviceCanStartVoice(); + } catch (err) { + if (!this.data.voiceState) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, '==== checkDeviceCanStartVoice error', err); + this.stopVoice(); + return; + } + + if (!this.data.voiceState) { + // 已经stop了 + return; + } + // 检查通过,开始对讲 + console.log(`[${this.data.innerId}]`, '==== checkCanStartVoice success'); + + // 通知事件 + this.triggerEvent('beforeStartVoice', { voiceOp: this.data.voiceOp }); + + if (this.data.needPusher) { + this.doStartVoiceByPusher(e); + } else { + this.doStartVoiceByRecorder(e); + } + }, + doStartVoiceByRecorder(e) { + // 每种采样率有对应的编码码率范围有效值,设置不合法的采样率或编码码率会导致录音失败 + // 具体参考 https://developers.weixin.qq.com/miniprogram/dev/api/media/recorder/RecorderManager.start.html + const { options } = voiceConfigMap[this.properties.intercomType]; + + // 弄个副本,以免被修改 + let voiceOptions; + if (e?.currentTarget?.dataset?.recorderCfg) { + const [numberOfChannels, sampleRate, encodeBitRate] = e?.currentTarget?.dataset?.recorderCfg + .split('-') + .map((v) => Number(v)); + const recorderOptions = { + numberOfChannels, // 录音通道数 + sampleRate, // 采样率 + encodeBitRate, // 编码码率 + }; + voiceOptions = { ...options, ...recorderOptions }; + } else { + voiceOptions = { ...options }; + } + + console.log(`[${this.data.innerId}]`, 'do doStartVoiceByRecorder', this.properties.targetId, voiceOptions); + this.setData({ voiceState: VoiceStateEnum.preparing }); + xp2pManager + .startVoice(this.properties.targetId, voiceOptions, { + onPause: (res) => { + console.log(`[${this.data.innerId}]`, 'voice onPause', res); + // 简单点,recorder暂停就停止语音对讲 + this.stopVoice(); + }, + onStop: (res) => { + console.log(`[${this.data.innerId}]`, 'voice onStop', res); + if (!res.willRestart) { + // 如果是到时间触发的,插件会自动续期,不自动restart的才需要stopVoice + this.stopVoice(); + } + }, + }) + .then((res) => { + if (!this.data.voiceState) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, 'startVoice success', res); + this.setData({ voiceState: VoiceStateEnum.sending }); + }) + .catch((errcode) => { + if (!this.data.voiceState) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, 'startVoice fail', errcode); + this.showToast(errcode === Xp2pManagerErrorEnum.NoAuth ? '请授权小程序访问麦克风' : '发起语音对讲失败'); + this.stopVoice(); + }); + }, + doStartVoiceByPusher(e) { + const { options } = voiceConfigMap[this.properties.intercomType]; + + // 弄个副本,以免被修改 + const voiceOptions = { ...options }; + + const needRecord = parseInt(e?.currentTarget?.dataset?.needRecord, 10); + + console.log(`[${this.data.innerId}]`, 'do doStartVoiceByPusher', this.properties.targetId, voiceOptions); + this.setData({ voiceState: VoiceStateEnum.preparing }); + xp2pManager + .startVoiceData(this.properties.targetId, voiceOptions, { + onStop: () => { + if (!this.data.voiceState) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, 'voice onStop'); + this.stopVoice(); + }, + onComplete: () => { + if (!this.data.voiceState) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, 'voice onComplete'); + this.stopVoice(); + }, + }) + .then((writer) => { + if (!this.data.voiceState) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, 'startVoiceData success', writer); + + let writerForPusher = writer; + if (needRecord) { + // 录制,方便验证,正式版本不需要 + const fileName = [ + `voice-${this.properties.productId}-${this.properties.deviceName}`, + sysInfo.platform, + this.data.pusherProps.isRTC ? 'RTC' : 'SD', + ].join('-'); + const voiceFileObj = voiceManager.openRecordFile(fileName); + this.userData.voiceFileObj = voiceFileObj; + writerForPusher = { + addChunk: (chunk) => { + // 收到pusher的数据 + // 写文件,不直接用 voiceFileObj 是因为 userData.voiceFileObj 后面可能会变,以 userData 里的为准 + this.userData.voiceFileObj && voiceManager.writeRecordFile(this.userData.voiceFileObj, chunk); + // 写到xp2p语音请求里 + writer.addChunk(chunk); + }, + }; + } + + // 启动推流,这时还不能发送数据 + console.log(`[${this.data.innerId}]`, 'call pusher.start, pusherProps', this.data.pusherProps); + this.setData({ voiceState: VoiceStateEnum.starting }); + this.userData.pusher.start({ + writer: writerForPusher, + success: (res) => { + console.log(`[${this.data.innerId}]`, 'voice pusher start success', res); + this.setData({ voiceState: VoiceStateEnum.sending }); + }, + fail: (err) => { + console.log(`[${this.data.innerId}]`, 'voice pusher start fail', err); + this.stopVoice(); + }, + }); + }) + .catch((errcode) => { + if (!this.data.voiceState) { + // 已经stop了 + return; + } + console.log(`[${this.data.innerId}]`, 'startVoiceData fail', errcode); + this.showToast('对讲失败'); + this.stopVoice(); + }); + }, + stopVoice() { + console.log(`[${this.data.innerId}]`, 'stopVoice'); + if (!this.data.voiceState) { + console.log(`[${this.data.innerId}]`, 'not voicing'); + return; + } + + console.log(`[${this.data.innerId}]`, 'do stopVoice', this.properties.targetId, this.data.voiceState); + + const { voiceFileObj } = this.userData; + this.userData.voiceFileObj = null; + + const { voiceState } = this.data; + this.setData({ voiceState: '' }); + + if (voiceFileObj) { + voiceManager.closeRecordFile(voiceFileObj); + } + + if (this.data.needPusher) { + // 如果是pusher,先停止采集语音 + if (voiceState === VoiceStateEnum.starting || voiceState === VoiceStateEnum.sending) { + this.userData.pusher?.stop(); + } + } else { + // 如果是recorder,p2p模块里的stopVoice里会停止recorderManager + } + xp2pManager.stopVoice(this.properties.targetId); + + // 通知事件 + this.triggerEvent('afterStopVoice', { voiceOp: this.data.voiceOp }); + }, + toggleModifyPusher() { + if (!this.data.isModifyPusher) { + this.stopVoice(); + this.setData({ + isModifyPusher: true, + pusherReady: false, + pusherError: null, + }); + this.userData.pusher = null; + } else { + this.setData({ + isModifyPusher: false, + }, () => { + this.getPusherComp(); + }); + } + }, + goRecordList() { + wx.navigateTo({ + url: `/pages/user-files/files?name=${voiceManager.name}`, + }); + }, + voiceOpChanged(e) { + this.setData({ + voiceOp: e.detail.value, + }); + }, + switchPusherPropCheck(e) { + const { pusherProps } = this.data; + + const { field } = e.currentTarget.dataset; + pusherProps[field] = e.detail.value; + + this.setData({ + pusherProps, + }); + }, + }, +}); diff --git a/demo/miniprogram/components/iot-p2p-voice/voice.json b/demo/miniprogram/components/iot-p2p-voice/voice.json new file mode 100644 index 0000000..32640e0 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-voice/voice.json @@ -0,0 +1,3 @@ +{ + "component": true +} \ No newline at end of file diff --git a/demo/miniprogram/components/iot-p2p-voice/voice.wxml b/demo/miniprogram/components/iot-p2p-voice/voice.wxml new file mode 100644 index 0000000..6953b5f --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-voice/voice.wxml @@ -0,0 +1,99 @@ + + + + intercomType: {{intercomType}} + voiceState: {{voiceState}} + + + + + + + + intercomType: {{intercomType}} + voiceState: {{voiceState}} + pusherReady: {{pusherReady}} + pusherProps: + {{!isModifyPusher ? '修改 pusher 属性' : '修改完成'}} + + + + + + + + + 录音管理 + + + + intercomType: {{intercomType}} + voiceState: {{voiceState}} + 对讲期间: + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/miniprogram/components/iot-p2p-voice/voice.wxss b/demo/miniprogram/components/iot-p2p-voice/voice.wxss new file mode 100644 index 0000000..4f19157 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-voice/voice.wxss @@ -0,0 +1,2 @@ +@import '../../common.wxss'; +@import '../../input.wxss'; diff --git a/demo/miniprogram/components/mock-p2p-player/player.js b/demo/miniprogram/components/mock-p2p-player/player.js new file mode 100644 index 0000000..7e2ee13 --- /dev/null +++ b/demo/miniprogram/components/mock-p2p-player/player.js @@ -0,0 +1,173 @@ +Component({ + behaviors: ['wx://component-export'], + properties: { + type: { + type: String, + value: 'livePlayer', // livePlayer, mjpgPlayer + }, + }, + data: { + hasCtx: false, + totalBytes: 0, // 仅显示用,计算用 this.userData.totalBytes + }, + lifetimes: { + created() { + // 渲染无关,不放在data里,以免影响性能 + this.userData = { + ctx: null, + chunkCount: 0, + totalBytes: 0, + }; + }, + attached() {}, + ready() { + this.prepare(); + }, + detached() { + this.clearStreamData(); + if (this.userData?.ctx?.isPlaying) { + this.userData.ctx.stop(); + } + }, + error() {}, + }, + methods: { + prepare() { + // 构造ctx + const livePlayerContext = { + isPlaying: false, // play/stop 用 + isPaused: false, // pause/resume 用 + play: ({ success, fail, complete } = {}) => { + if (livePlayerContext.isPlaying) { + fail && fail({ errMsg: 'already playing' }); + complete && complete(); + return; + } + this.clearStreamData(); + livePlayerContext.isPlaying = true; + // livePlayerContext.isPaused = false; + setTimeout(() => { + success && success(); + complete && complete(); + !livePlayerContext.isPaused && this.triggerEvent('playerStartPull', {}); + }, 0); + }, + stop: ({ success, fail, complete } = {}) => { + if (!livePlayerContext.isPlaying) { + fail && fail({ errMsg: 'not playing' }); + complete && complete(); + return; + } + this.clearStreamData(); + livePlayerContext.isPlaying = false; + // livePlayerContext.isPaused = false; + // 这个是立刻调用的 + this.triggerEvent('playerClose', { error: { code: 'USER_CLOSE' } }); + setTimeout(() => { + success && success(); + complete && complete(); + }, 0); + }, + pause: ({ success, fail, complete } = {}) => { + if (!livePlayerContext.isPlaying) { + fail && fail({ errMsg: 'not playing' }); + complete && complete(); + return; + } + if (livePlayerContext.isPaused) { + fail && fail({ errMsg: 'already paused' }); + complete && complete(); + return; + } + this.clearStreamData(); + livePlayerContext.isPaused = true; + setTimeout(() => { + success && success(); + complete && complete(); + this.triggerEvent('playerClose', { error: { code: 'LIVE_PLAYER_CLOSED' } }); + }, 0); + }, + resume: ({ success, fail, complete } = {}) => { + if (!livePlayerContext.isPlaying) { + fail && fail({ errMsg: 'not playing' }); + complete && complete(); + return; + } + if (!livePlayerContext.isPaused) { + fail && fail({ errMsg: 'not paused' }); + complete && complete(); + return; + } + this.clearStreamData(); + livePlayerContext.isPaused = false; + setTimeout(() => { + success && success(); + complete && complete(); + this.triggerEvent('playerStartPull', {}); + }, 0); + }, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + snapshot: ({ success, fail, complete } = {}) => { + setTimeout(() => { + fail && fail({ errMsg: 'mock-player not support snapshot' }); + complete && complete(); + }, 0); + }, + }; + + this.userData.ctx = livePlayerContext; + this.setData({ + hasCtx: true, + }); + + setTimeout(() => { + const fieldName = `${this.properties.type}Context`; + this.triggerEvent('playerReady', { + [fieldName]: livePlayerContext, + playerExport: { + setHeaders: this.setHeaders.bind(this), + addChunk: this.addChunk.bind(this), + // finishMedia: this.finishMedia.bind(this), + // abortMedia: this.abortMedia.bind(this), + }, + }); + }, 10); + }, + setHeaders(_headers) {}, + addChunk(data) { + this.userData.chunkCount++; + this.userData.totalBytes += data.byteLength; + if (this.userData.chunkCount === 1) { + this.setData({ + totalBytes: this.userData.totalBytes, // 第一个立刻刷新 + }); + } + // 控制刷新频率 + this.refreshBytesDelay(); + }, + refreshBytesDelay() { + if (this.refreshTimer) { + return; + } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + this.setData({ + totalBytes: this.userData?.totalBytes || 0, // 把数据更新到界面 + }); + }, 1000); + }, + clearStreamData() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + if (this.userData) { + this.userData.chunkCount = 0; + this.userData.totalBytes = 0; + } + this.setData({ + totalBytes: 0, + }); + }, + }, +}); diff --git a/demo/miniprogram/components/mock-p2p-player/player.json b/demo/miniprogram/components/mock-p2p-player/player.json new file mode 100644 index 0000000..32640e0 --- /dev/null +++ b/demo/miniprogram/components/mock-p2p-player/player.json @@ -0,0 +1,3 @@ +{ + "component": true +} \ No newline at end of file diff --git a/demo/miniprogram/components/mock-p2p-player/player.wxml b/demo/miniprogram/components/mock-p2p-player/player.wxml new file mode 100644 index 0000000..cb3fb13 --- /dev/null +++ b/demo/miniprogram/components/mock-p2p-player/player.wxml @@ -0,0 +1,7 @@ + + mockType: {{type}} + + totalBytes: {{totalBytes}} + + preparing... + \ No newline at end of file diff --git a/demo/miniprogram/components/mock-p2p-player/player.wxss b/demo/miniprogram/components/mock-p2p-player/player.wxss new file mode 100644 index 0000000..fd9d013 --- /dev/null +++ b/demo/miniprogram/components/mock-p2p-player/player.wxss @@ -0,0 +1,8 @@ +.mock-p2p-player { + box-sizing: border-box; + background-color: black; + color: white; + width: 100%; + height: 100%; + padding: 20rpx; +} \ No newline at end of file diff --git a/demo/miniprogram/config/config.js b/demo/miniprogram/config/config.js index be030ca..e59e74e 100644 --- a/demo/miniprogram/config/config.js +++ b/demo/miniprogram/config/config.js @@ -1,3 +1,4 @@ +/* eslint-disable camelcase, @typescript-eslint/naming-convention */ import devices from './devices'; import serverStreams from './streams'; @@ -10,68 +11,6 @@ const config = { appSecretKey: 'b62XcOoDcvJOgnibM8iKFVgVsXcdxNda', appPackage: 'ios.test.com', }, - commandMap: { - getLiveStatus: { - cmd: 'get_device_st', - params: { - type: 'live', - quality: 'super', - }, - }, - getVoiceStatus: { - cmd: 'get_device_st', - params: { - type: 'voice', - }, - }, - getRecordDates: { - cmd: 'get_month_record', - params: (date) => { - const year = date.getFullYear(); - let month = String(date.getMonth() + 1); - if (month.length < 2) { - month = `0${month}`; - } - return { time: `${year}${month}` }; // yyyymm - }, - dataHandler: (oriData) => { - const dates = []; - const tmpList = parseInt(oriData.video_list, 10).toString(2).split('').reverse(); - const tmpLen = tmpList.length; - for (let i = 0; i < tmpLen; i++) { - if (tmpList[i] === '1') { - dates.push(i + 1); - } - } - return dates; - }, - }, - getRecordVideos: { - cmd: 'get_record_index', - params: (date) => { - const startDate = new Date(date); - startDate.setHours(0, 0, 0, 0); - const start_time = startDate.getTime() / 1000; - const end_time = start_time + 3600 * 24 - 1; - return { start_time, end_time }; - }, - }, - getPlaybackStatus: { - cmd: 'get_device_st', - params: { - type: 'playback', - }, - }, - getPlaybackProgress: { - cmd: 'playback_progress', - }, - pausePlayback: { - cmd: 'playback_pause', - }, - resumePlayback: { - cmd: 'playback_resume', - }, - }, }; // 方便测试用的预置数据 @@ -80,7 +19,7 @@ const totalData = {}; // ipc设备都加进去 for (const key in devices) { totalData[key] = { - mode: 'ipc', + p2pMode: 'ipc', targetId: key, ...devices[key], }; @@ -94,7 +33,7 @@ if (recentIPC) { // server流都加进去 for (const key in serverStreams) { totalData[key] = { - mode: 'server', + p2pMode: 'server', targetId: key, ...serverStreams[key], }; diff --git a/demo/miniprogram/config/devices.js b/demo/miniprogram/config/devices.js index 020394c..dc6d857 100644 --- a/demo/miniprogram/config/devices.js +++ b/demo/miniprogram/config/devices.js @@ -7,45 +7,105 @@ * productId: string 摄像头的 productId * deviceName: string 摄像头的 deviceName * xp2pInfo: string 摄像头的 xp2pInfo - * liveParams: string 摄像头的直播参数,默认 action=live&channel=0&quality=super + * liveParams: string 摄像头的直播参数,默认 action=live&channel=0&quality=standard * playbackParams: string 摄像头的回放参数,默认 action=playback&channel=0 * liveStreamDomain: string 1v1连接过多时自动转到1v多模式的server域名 - * needCheckStream: boolean 播放前先检查能否拉流,默认false - * needPusher: boolean 语音对讲使用pusher采集数据,默认false + * options: + * needMjpg: boolean 需要图片流,默认false + * needCheckStream: boolean 播放前先检查能否拉流,默认false + * intercomType: string 对讲类型,Recorder/Pusher/DuplexVideo,默认Recorder */ // 这些是预置的ipc设备 const devices = { - ipc0: { + test_mjpg: { showInHomePageBtn: true, - productId: '9L1S66FZ3Q', - deviceName: 'test_34683636_4', - xp2pInfo: 'XP2P1Xl5RwePR/gacCZqsX8aladI%2.3.5', - liveParams: 'action=live&channel=0&quality=standard', + productName: 'Mjpg', + productId: 'AQTV2839QJ', + deviceName: 'sp02_33925210_16', + xp2pInfo: 'XP2PDhFRfd+PLWpEndXBeTPv9g==%2.4.29', + liveParams: 'action=live-audio&channel=0', + liveMjpgParams: 'action=live-mjpg&channel=0', + playbackParams: 'action=playback-audio&channel=0', + playbackMjpgParams: 'action=playback-mjpg&channel=0', + options: { + needMjpg: true, + }, + }, + test_kds: { + showInHomePageBtn: true, + productName: 'KDS', + productId: 'LWY363KD9E', + deviceName: 'K20_76758069_60', + xp2pInfo: 'XP2Pfwv20xj36l70+nW2pVyqJA==%2.4.29', + liveParams: 'action=live&channel=0', playbackParams: 'action=playback&channel=0', }, - ipc_download: { + test_lock: { showInHomePageBtn: true, - productId: '65CC3I8Q4G', - deviceName: 'llynne_41877702_5', - xp2pInfo: 'XP2PRaiH8GqimBOseoPFEHWCimrd%2.3.11', + productName: 'Lock', + productId: 'WJPEXAPK6Y', + deviceName: 'M7L1_75239714_2', + xp2pInfo: 'XP2PSJvxFsCQ8HtDkq3mUJpuYi+v%2.3.15', + liveParams: 'action=live&channel=0', + playbackParams: 'action=playback&channel=0', + options: { + intercomType: 'Pusher', + }, }, - ipc_test: { + test_android: { showInHomePageBtn: true, - productId: 'LWY363KD9E', - deviceName: 'K20_72261264_7', - xp2pInfo: 'XP2PX9jEb4ktpH7AGkeYG6FQ4w==%2.4.23', - needPusher: true, + productName: 'Android', + productId: '9L1S66FZ3Q', + deviceName: 'test_34683636_1', + xp2pInfo: 'XP2PK6vh01xsBCJ2/by7Dawe9w==%2.4.0', + liveParams: 'action=live&channel=0&quality=high', + options: { + intercomType: 'Pusher', + }, }, - debug: { - // showInHomePageBtn: true, - // showInHomePageNav: true, + 'of-2': { + showInHomePageBtn: true, productId: '9L1S66FZ3Q', - deviceName: 'test_34683636_3', - xp2pInfo: 'XP2PcDd3JlQPiXxDzaKo4YvCqnUq%2.3.5', - liveParams: 'action=live&channel=0&quality=super', - playbackParams: 'action=playback&channel=0', + deviceName: 'test_34683636_1', + xp2pInfo: 'XP2PK6vh01xsBCJM9aXYOKgu9A==%2.4.0', + liveParams: 'action=live&channel=0&quality=high', + }, + 'of-1': { + showInHomePageBtn: true, + productId: 'WJPEXAPK6Y', + deviceName: 'M7L1_75239714_2', + xp2pInfo: 'XP2PSJvxFsCQ8Htdkv/ZVqYPQiOb%2.3.15', + }, + 'ipc-2': { + showInHomePageBtn: false, + productId: 'AQTV2839QJ', + deviceName: 'sp02_33925210_13', + xp2pInfo: 'XP2PYYAldyTto1rnaA3tJSl++g==%2.4.x', + }, + 'ipc-1': { + showInHomePageBtn: false, + productId: 'AQTV2839QJ', + deviceName: 'sp02_33925210_13', + xp2pInfo: 'XP2PJsQdaV/urH33eTioM3BTiWzI%2.3.x', + }, + wuxing2_4: { + showInHomePageBtn: false, + productId: 'H0O409AOUL', + deviceName: 'HH_67772521_23', + xp2pInfo: 'XP2PT/SH8iZ0+kK9FZi8mYU2hg==%2.4.28', + }, + wuxing2_3: { + showInHomePageBtn: false, + productId: 'H0O409AOUL', + deviceName: 'HH_67772521_8', + xp2pInfo: 'XP2P7PMO9hA6z5TEUyVRmbBVb08B%2.3.15', }, }; +Object.values(devices).forEach((device) => { + device.liveParams = device.liveParams || 'action=live&channel=0&quality=standard'; + device.playbackParams = device.playbackParams || 'action=playback&channel=0'; +}); + export default devices; diff --git a/demo/miniprogram/exportForPlugin.js b/demo/miniprogram/exportForPlugin.js new file mode 100644 index 0000000..12f218f --- /dev/null +++ b/demo/miniprogram/exportForPlugin.js @@ -0,0 +1,3 @@ +module.exports = { + wx, +}; diff --git a/demo/miniprogram/lib/recordManager.js b/demo/miniprogram/lib/recordManager.js index 3a69ed4..5000968 100644 --- a/demo/miniprogram/lib/recordManager.js +++ b/demo/miniprogram/lib/recordManager.js @@ -45,7 +45,14 @@ class RecordManager { try { const files = fileSystem.readdirSync(this.baseDir); - console.log('RecordManager: getSavedRecordList success', files); + if (files.length> 1) { + files.sort((a, b) => { + if (a < b) return 1; + if (a> b) return -1; + return 0; + }); + } + console.log('RecordManager: getSavedRecordList success, files.length', files.length); return files; } catch (err) { console.error('RecordManager: getSavedRecordList error', err); @@ -79,15 +86,14 @@ class RecordManager { if (!fileName) { return Promise.reject({ errMsg: 'param error' }); } - const filePath = `${this.baseDir}/${fileName}`; - console.log('RecordManager: getFileInfo', fileName, filePath); return new Promise((resolve, reject) => { const filePath = `${this.baseDir}/${fileName}`; + // console.log('RecordManager: getFileInfo', fileName, filePath); fileSystem.getFileInfo({ filePath, success: (res) => { - console.log('RecordManager: getFileInfo success', fileName, res); + // console.log('RecordManager: getFileInfo success', fileName, res); resolve({ fileName, filePath, @@ -95,7 +101,7 @@ class RecordManager { }); }, fail: (err) => { - console.log('RecordManager: getFileInfo fail', fileName, err); + // console.log('RecordManager: getFileInfo fail', fileName, err); reject(err); }, }); @@ -113,13 +119,13 @@ class RecordManager { const filePath = `${this.baseDir}/${fileName}`; fileSystem.readFile({ filePath, - success(res) { + success: (res) => { resolve({ fileName, ...res, }); }, - fail(err) { + fail: (err) => { console.log('RecordManager: readFile fail', fileName, err); reject(err); }, @@ -138,6 +144,40 @@ class RecordManager { } } + // 添加文件 + addFile(fileName, srcFilePath) { + if (!fileName) { + return Promise.reject({ errMsg: 'invalid fileName' }); + } + if (!srcFilePath) { + return Promise.reject({ errMsg: 'invalid srcFilePath' }); + } + const filePath = `${this.baseDir}/${fileName}`; + console.log('RecordManager: addFile', fileName, filePath); + + this.prepareDir(); + + let isExist = false; + try { + fileSystem.accessSync(filePath); + isExist = true; + } catch (err) {} + + if (isExist) { + console.log('RecordManager: addFile fail, file exist'); + return Promise.reject({ errMsg: 'file already exist' }); + } + + return new Promise((resolve, reject) => { + fileSystem.saveFile({ + tempFilePath: srcFilePath, + filePath, + success: resolve, + fail: reject, + }); + }); + } + // 创建文件 prepareFile(fileName) { if (!fileName) { @@ -150,7 +190,7 @@ class RecordManager { let isExist = true; try { - fileSystemManager.accessSync(filePath); + fileSystem.accessSync(filePath); } catch (err) { isExist = false; } @@ -216,6 +256,20 @@ class RecordManager { } */ + // 按文件名写文件 + writeFile(fileName, data) { + if (!fileSystem || !fileName || !data?.byteLength) { + return -1; + } + try { + const filePath = `${this.baseDir}/${fileName}`; + fileSystem.writeFileSync(filePath, data, 'binary'); + return data.byteLength; + } catch (err) { + return -1; + } + } + // 打开文件(通用,不改文件名,也不清理之前的文件) openFile(fileName) { if (!fileName) { @@ -245,18 +299,18 @@ class RecordManager { } // 打开录像文件 - openRecordFile(recordFilename) { + openRecordFile(recordFilename, fileType = 'flv') { if (!recordFilename) { return null; } console.log('RecordManager: openRecordFile', recordFilename); - // 每次录之前清掉之前的录像,避免占用过多空间 - this.removeSavedRecordList(); + // 每次录之前清掉之前的录像,避免占用过多空间,demo就不清了,方便定位问题 + // this.removeSavedRecordList(); // 录像文件名自动带上录制时间 - const fixedFilename = recordFilename.replace(/\.flv$/, '').replace(/\W/g, '-'); - const fileName = `${fixedFilename}.${toDateTimeFilename(new Date())}.flv`; + const fixedFilename = recordFilename.replace(new RegExp(`\\.${fileType}$`), '').replace(/\W/g, '-'); + const fileName = `${fixedFilename}.${toDateTimeFilename(new Date())}.${fileType || 'flv'}`; return this.openFile(fileName); } @@ -326,28 +380,39 @@ class RecordManager { } } - async saveVideoToAlbum(fileName) { + async saveToAlbum(fileName) { const filePath = `${this.baseDir}/${fileName}`; + let api = ''; + if (/\.mp4$/i.test(fileName) || /\.flv$/i.test(fileName) || /\.mjpg$/i.test(fileName)) { + api = 'saveVideoToPhotosAlbum'; + } else if (/\.jpg$/i.test(fileName) || /\.jpeg$/i.test(fileName)) { + api = 'saveImageToPhotosAlbum'; + } else { + console.log('RecordManager: saveToAlbum, invalid format'); + throw new Error('invalid format'); + } + try { await checkAuthorize('scope.writePhotosAlbum'); } catch (err) { - console.log('RecordManager: saveVideoToAlbum, checkAuthorize fail', err); + console.log('RecordManager: saveToAlbum, checkAuthorize fail', err); throw err; } + try { - const res = await wx.saveVideoToPhotosAlbum({ + const res = await wx[api]({ filePath, }); - console.log('RecordManager: saveVideoToAlbum, saveVideoToPhotosAlbum success', res); + console.log(`RecordManager: saveToAlbum, ${api} success`, res); return res; } catch (err) { - console.log('RecordManager: saveVideoToAlbum, saveVideoToPhotosAlbum fail', err); + console.log(`RecordManager: saveToAlbum, ${api} fail`, err); throw err; } } - async sendDocument(fileName) { + async sendFileByOpenDocument(fileName) { const filePath = `${this.baseDir}/${fileName}`; await wx.setClipboardData({ @@ -365,10 +430,30 @@ class RecordManager { showMenu: true, fileType: 'doc', }); - console.log('RecordManager: sendDocument, openDocument success', res); + console.log('RecordManager: openDocument success', res); + return res; + } catch (err) { + console.log('RecordManager: openDocument fail', err); + throw err; + } + } + + async sendFile(fileName) { + const filePath = `${this.baseDir}/${fileName}`; + + if (!wx.shareFileMessage) { + // 兼容低版本 + return await this.sendFileByOpenDocument(fileName); + } + + try { + const res = await wx.shareFileMessage({ + filePath, + }); + console.log('RecordManager: shareFileMessage success', res); return res; } catch (err) { - console.log('RecordManager: sendDocument, openDocument fail', err); + console.log('RecordManager: shareFileMessage fail', err); throw err; } } @@ -376,7 +461,7 @@ class RecordManager { const mgrMap = {}; export const getRecordManager = (name) => { - const key = name || 'records'; + const key = name || 'others'; if (!mgrMap[key]) { mgrMap[key] = new RecordManager(key); } diff --git a/demo/miniprogram/lib/xp2pManager.js b/demo/miniprogram/lib/xp2pManager.js index 97fc958..69e9eb0 100644 --- a/demo/miniprogram/lib/xp2pManager.js +++ b/demo/miniprogram/lib/xp2pManager.js @@ -1,3 +1,4 @@ +/* eslint-disable camelcase, @typescript-eslint/naming-convention */ import config from '../config/config'; import { checkAuthorize } from '../utils'; @@ -14,7 +15,15 @@ const { appParams } = config; const xp2pPlugin = requirePlugin('xp2p'); const p2pExports = xp2pPlugin.p2p; -const p2pTimeout = 6000; + +console.log('p2pExports', p2pExports.XP2PVersion, p2pExports); +// p2pExports.enableHttpLog(true); +// p2pExports.enableNetLog(true); +// p2pExports.enableXNTPLog(true); +// p2pExports.enableADP2PLog(true); // 1v多log +p2pExports.enableAPP_P2PLog(true); // IoT应用层log + +const p2pTimeout = 10000; let playerPlugin; @@ -31,7 +40,7 @@ const parseCommandResData = (data) => { class Xp2pManager { get P2PPlayerVersion() { - return playerPlugin.Version; + return playerPlugin?.Version; } get XP2PVersion() { @@ -42,16 +51,18 @@ class Xp2pManager { return p2pExports.XP2PLocalEventEnum; } + get XP2PServiceEventEnum() { + return p2pExports.XP2PServiceEventEnum; + } + get XP2PEventEnum() { return p2pExports.XP2PEventEnum; } - // eslint-disable-next-line camelcase get XP2PNotify_SubType() { return p2pExports.XP2PNotify_SubType; } - // eslint-disable-next-line camelcase get XP2PDevNotify_SubType() { return p2pExports.XP2PDevNotify_SubType; } @@ -72,6 +83,10 @@ class Xp2pManager { return this._localPeername; } + get localPeername2() { + return this._localPeername2; + } + get networkChanged() { return !!this._networkChanged; } @@ -118,6 +133,12 @@ class Xp2pManager { if (!playerPlugin) { playerPlugin = requirePlugin('wechat-p2p-player'); + console.log('Xp2pManager: playerPlugin', playerPlugin); + // playerPlugin.enableHttpLog(false); + // playerPlugin.enableRtmpLog(false); + playerPlugin.initHttp && playerPlugin.initHttp({ + errorCallback: this._localHttpErrorHandler.bind(this), + }); } console.log('Xp2pManager: P2PPlayerVersion', this.P2PPlayerVersion); @@ -159,7 +180,7 @@ class Xp2pManager { getXp2pPluginInfo() { try { - return xp2pPlugin.getPluginInfo && xp2pPlugin.getPluginInfo(); + return p2pExports.getPluginInfo && p2pExports.getPluginInfo(); } catch (err) { // 低版本插件没有这个接口 console.error('getXp2pPluginInfo error', err); @@ -257,11 +278,39 @@ class Xp2pManager { return reject(Xp2pManagerErrorEnum.Timeout); }, p2pTimeout); - p2pExports - .init({ - appParams, - eventHandler: this._eventHandler.bind(this), // 需要xp2p插件1.1.1以上版本 - }) + // p2pExports + // .init({ + // appParams, + // deviceP2PVersion: '2.4', + // eventHandler: this._eventHandler.bind(this), // 需要xp2p插件1.1.1以上版本 + // }) + + Promise.all([ + p2pExports + .init({ + appParams, + initParams: { + deviceP2PVersion: p2pExports.DeviceVersion.Device_2_3, + eventHandler: this._eventHandler.bind(this), // 需要xp2p插件1.1.1以上版本 + }, + }) + .then((singleRes) => { + console.log('Xp2pManager: init 2.3 delay', Date.now() - start); + return singleRes; + }), + p2pExports + .init({ + appParams, + initParams: { + deviceP2PVersion: p2pExports.DeviceVersion.Device_2_4, + eventHandler: this._eventHandler.bind(this), // 需要xp2p插件1.1.1以上版本 + }, + }) + .then((singleRes) => { + console.log('Xp2pManager: init 2.4 delay', Date.now() - start); + return singleRes; + }), + ]) .then((res) => { clearTimeout(timer); if (this._promise !== promise) { @@ -269,27 +318,29 @@ class Xp2pManager { } console.log('Xp2pManager: init res', res, 'delay', Date.now() - start); - if (res === 0) { - const localPeername = p2pExports.getLocalXp2pInfo(); - console.log('Xp2pManager: localPeername', localPeername); - this._state = 'inited'; - this._localPeername = localPeername; - this._promise = null; - } else { - this._resetXP2PData(); - } + this._state = 'inited'; + this._localPeername = res[0].localXp2pInfo; + this._localPeername2 = res[1].localXp2pInfo; + this._promise = null; - return resolve(res); + // setTimeout(() => { + // res.reset().then(r => { + // console.log('Xp2pManager: module inner reset res', r); + // this._localPeername2 = r.localXp2pInfo; + // }); + // }, 5000); + + return resolve(0); }) - .catch((errcode) => { + .catch((err) => { clearTimeout(timer); if (this._promise !== promise) { return; } - console.error('Xp2pManager: init error', errcode); + console.error('Xp2pManager: init error', err); this._resetXP2PData(); - return reject(errcode); + return reject(err); }); }); @@ -332,8 +383,22 @@ class Xp2pManager { return reject(Xp2pManagerErrorEnum.Timeout); }, p2pTimeout); - p2pExports - .resetP2P() + Promise.all([ + p2pExports + .getModule(p2pExports.DeviceVersion.Device_2_3) + .reset() + .then((singleRes) => { + console.log('Xp2pManager: reset 2.3 delay', Date.now() - start); + return singleRes; + }), + p2pExports + .getModule(p2pExports.DeviceVersion.Device_2_4) + .reset() + .then((singleRes) => { + console.log('Xp2pManager: reset 2.4 delay', Date.now() - start); + return singleRes; + }), + ]) .then((res) => { clearTimeout(timer); if (this._promise !== promise) { @@ -341,15 +406,10 @@ class Xp2pManager { } console.log('Xp2pManager: resetP2P res', res, 'delay', Date.now() - start); - if (res === 0) { - const localPeername = p2pExports.getLocalXp2pInfo(); - console.log('Xp2pManager: localPeername', localPeername); - this._state = 'inited'; - this._localPeername = localPeername; - this._promise = null; - } else { - this.destroyModule(); - } + this._state = 'inited'; + this._localPeername = res[0].localXp2pInfo; + this._localPeername2 = res[1].localXp2pInfo; + this._promise = null; return resolve(res); }) @@ -383,18 +443,18 @@ class Xp2pManager { } updateServiceCallbacks(targetId, callbacks) { - console.log('Xp2pManager: updateP2PServiceCallbacks', targetId, callbacks); + console.log('Xp2pManager: updateServiceCallbacks', targetId, callbacks); return p2pExports.updateServiceCallbacks(targetId, callbacks); } - startStream(targetId, { flv, dataCallback }) { - console.log('Xp2pManager: startStream', targetId); - return p2pExports.startStream(targetId, { flv, dataCallback }); + startStream(targetId, { flv, msgCallback, dataCallback }) { + console.log('Xp2pManager: startStream', targetId, flv); + return p2pExports.startStream(targetId, { flv, msgCallback, dataCallback }); } - stopStream(targetId) { - console.log('Xp2pManager: stopStream', targetId); - return p2pExports.stopStream(targetId); + stopStream(targetId, streamType) { + console.log('Xp2pManager: stopStream', targetId, streamType); + return p2pExports.stopStream(targetId, streamType); } startVoice(targetId, options, callbacks) { @@ -408,6 +468,7 @@ class Xp2pManager { // 语音对讲需要recorderManager const recorderManager = wx.getRecorderManager(); + console.log('Xp2pManager: do startVoice', targetId, options); p2pExports .startVoice(targetId, recorderManager, options, callbacks) .then((res) => { @@ -541,6 +602,19 @@ class Xp2pManager { } } + _localHttpErrorHandler(err) { + console.log('Xp2pManager: _localHttpErrorHandler', err); + const timestamp = Date.now(); + if (err?.errNum === 53) { + // ios 退后台一段时间后,如果没有网络传输,系统会中断网络服务,xp2p插件未通知出来,player插件能通过 TCPServer 检测到,errNum 53 + if (this._appHideTimestamp) { + console.log(`Xp2pManager: http errNum ${err?.errNum} after appHide ${timestamp - this._appHideTimestamp}`); + } + // 仅记录,再次触发播放时会reset + this._networkChanged = { detail: err, timestamp }; + } + } + checkRecordAuthorize() { return checkAuthorize('scope.record'); } @@ -549,9 +623,6 @@ class Xp2pManager { let xp2pManager = null; export const getXp2pManager = () => { if (!xp2pManager) { - p2pExports.enableHttpLog(false); - p2pExports.enableNetLog(false); - p2pExports.enableXNTPLog(false); xp2pManager = new Xp2pManager(); } return xp2pManager; diff --git a/demo/miniprogram/pages/index/index.js b/demo/miniprogram/pages/index/index.js index d3ee137..d59dca4 100644 --- a/demo/miniprogram/pages/index/index.js +++ b/demo/miniprogram/pages/index/index.js @@ -50,9 +50,9 @@ Page({ for (const key in devices) { const item = devices[key]; const navItem = { - mode: 'ipc', + p2pMode: 'ipc', cfg: key, - title: `${item.productId}/${item.deviceName}`, + title: `${item.productName || item.productId}/${item.deviceName}`, ...item, }; if (item.showInHomePageBtn) { @@ -66,7 +66,7 @@ Page({ for (const key in streams) { const item = streams[key]; const navItem = { - mode: 'server', + p2pMode: 'server', cfg: key, title: `1vN: ${item.serverName}/${getShortFlvName(item.flvFile)}`, ...item, @@ -90,7 +90,7 @@ Page({ const cfg = totalData.recentIPC || wx.getStorageSync('recentIPC'); this.setData({ recentIPCItem: cfg ? { - mode: 'ipc', + p2pMode: 'ipc', cfg: 'recentIPC', title: `${cfg.productId}/${cfg.deviceName}`, ...cfg, diff --git a/demo/miniprogram/pages/index/index.wxml b/demo/miniprogram/pages/index/index.wxml index 221dc9c..2ccdd6c 100644 --- a/demo/miniprogram/pages/index/index.wxml +++ b/demo/miniprogram/pages/index/index.wxml @@ -43,10 +43,13 @@ 跳转到 X-P2P 多播放器 {{firstServerStream.cfg}}+{{firstIPCStream.cfg}} --> 跳转到 下载管理页 - 跳转到 录像管理页 + 跳转到 录像管理页-flv + 跳转到 录像管理页-mjpg 跳转到 语音管理页 + 跳转到 Video 管理页 跳转到 P2P-Pusher 测试页 跳转到 LivePlayer 测试页 跳转到 Video 测试页 + diff --git a/demo/miniprogram/pages/test-local-mjpg-player/player.js b/demo/miniprogram/pages/test-local-mjpg-player/player.js new file mode 100644 index 0000000..e702c37 --- /dev/null +++ b/demo/miniprogram/pages/test-local-mjpg-player/player.js @@ -0,0 +1,227 @@ +import { getXp2pManager } from '../../lib/xp2pManager'; +import { getRecordManager } from '../../lib/recordManager'; + +const xp2pManager = getXp2pManager(); + +Page({ + data: { + systemInfo: {}, + pluginVersion: '', + showPlayerLog: true, + playerId: '', + playerReady: false, + playerCtx: null, + playerSrc: '', + playerAudioSrc: '', + playerLoop: false, + playStatus: '', // '' | 'playing' + log: '', + }, + onLoad(query) { + this.userData = { + // 渲染无关的尽量放这里 + recordManager: null, + mjpgPath: '', + }; + + const systemInfo = wx.getSystemInfoSync() || {}; + this.setData({ + systemInfo, + playerPluginVersion: xp2pManager.P2PPlayerVersion, + }); + + if (query.dirname && query.filename) { + const recordManager = getRecordManager(query.dirname); + this.userData.recordManager = recordManager; + this.userData.mjpgPath = `${recordManager.baseDir}/${query.filename}`; + + // 指定了录像的,自动创建 + this.bindCreatePlayer(); + } + }, + onUnload() { + this.data.playerId && this.bindDestroyPlayer(); + }, + showToast(content) { + wx.showToast({ + title: content, + icon: 'none', + }); + }, + addLog(str) { + this.setData({ log: `${this.data.log}${Date.now()} - ${str}\n` }); + }, + clearLog() { + this.setData({ log: '' }); + }, + onPlayerReady({ detail }) { + console.log('==== onPlayerReady', detail); + this.addLog('==== onPlayerReady'); + this.setData({ + playerReady: true, + playerCtx: detail.mjpgPlayerContext, + playerSrc: this.userData.mjpgPath, + }); + }, + onPlayerError({ detail }) { + console.error('==== onPlayerError', detail); + const code = detail?.error?.code; + this.addLog(`==== onPlayerError, code: ${code}`); + this.setData({ + playerReady: false, + playerCtx: null, + playerSrc: '', + playStatus: '', + }); + this.bindDestroyPlayer(); + + wx.showModal({ + content: `player错误: ${code}`, + showCancel: false, + }); + }, + onEnded({ detail }) { + console.log('==== onEnded', detail); + this.addLog('==== onEnded'); + this.setData({ + playStatus: '', + }); + }, + onStreamError({ detail }) { + console.error('==== onStreamError', detail); + this.addLog(`==== onStreamError, ${detail.errMsg}`); + this.setData({ + playStatus: '', + }); + }, + onImageLoad({ detail }) { + console.log('==== onImageLoad', detail); + this.addLog(`==== onImageLoad, ${detail.width} x ${detail.height}`); + }, + onImageError({ detail }) { + console.error('==== onImageError', detail); + this.addLog(`==== onImageError, ${detail.errMsg}`); + this.setData({ + playStatus: '', + }); + }, + onAudioError({ detail }) { + console.error('==== onAudioError', detail); + this.addLog(`==== onAudioError, ${detail.errMsg}`); + this.setData({ + playStatus: '', + }); + }, + bindCreatePlayer() { + if (this.data.playerId) { + console.log('already existed'); + return; + } + + this.addLog('create player'); + this.setData({ + playerId: 'mpeg-player-local-demo', + }); + }, + bindDestroyPlayer() { + if (!this.data.playerId) { + console.log('not existed'); + return; + } + this.bindClear(); + + this.addLog('destroy player'); + this.setData({ + playerId: '', + playerReady: false, + playerCtx: null, + }); + }, + async bindChoose({ currentTarget }) { + if (!this.data.playerCtx) { + console.log('player not ready'); + return; + } + const field = currentTarget.dataset.field || 'playerSrc'; + const ext = currentTarget.dataset.ext || ''; + if (this.data[field]) { + console.log(`already set ${field}`); + return; + } + let file; + try { + const res = await wx.chooseMessageFile({ + count: 1, + type: 'file', + extension: ext ? ext.split(',') : undefined, + }); + file = res.tempFiles[0]; + console.log('choose file res', file); + if (!file?.size) { + this.addLog('file empty'); + return; + } + } catch (err) { + console.error('choose file fail', err); + this.addLog('choose file fail'); + return; + } + this.addLog(`set ${field} ${file.path}`); + this.setData({ + [field]: file.path, + }); + }, + bindClear() { + this.bindStop(); + this.addLog('clear src'); + this.setData({ + playerSrc: '', + playerAudioSrc: '', + }); + }, + bindPlay({ currentTarget }) { + if (!this.data.playerCtx) { + console.log('player not ready'); + return; + } + if (!this.data.playerSrc) { + console.log('not set src'); + return; + } + this.addLog(`play ${this.data.playerSrc}`); + this.setData({ + playerLoop: parseInt(currentTarget.dataset.loop, 10), + playStatus: 'playing', + }); + this.data.playerCtx?.play(); + }, + bindStop() { + if (!this.data.playerCtx) { + console.log('player not ready'); + return; + } + if (!this.data.playerSrc) { + console.log('not set src'); + return; + } + this.addLog('stop'); + this.data.playerCtx.stop(); + this.setData({ + playStatus: '', + }); + }, + bindPause() { + if (!this.data.playerCtx) { + console.log('player not ready'); + return; + } + this.data.playerCtx?.pause(); + }, + bindResume() { + if (!this.data.playerCtx) { + console.log('player not ready'); + return; + } + this.data.playerCtx?.resume(); + }, +}); diff --git a/demo/miniprogram/pages/test-local-mjpg-player/player.json b/demo/miniprogram/pages/test-local-mjpg-player/player.json new file mode 100644 index 0000000..26d632d --- /dev/null +++ b/demo/miniprogram/pages/test-local-mjpg-player/player.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "Local-MPEG-Player 测试页", + "usingComponents": { + "local-mjpg-player": "plugin://wechat-p2p-player/local-mjpg-player" + } +} \ No newline at end of file diff --git a/demo/miniprogram/pages/test-local-mjpg-player/player.wxml b/demo/miniprogram/pages/test-local-mjpg-player/player.wxml new file mode 100644 index 0000000..ca0a3eb --- /dev/null +++ b/demo/miniprogram/pages/test-local-mjpg-player/player.wxml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + requirements: wx 8.0.10 + sdk 2.19.3 + system: {{systemInfo.system}} / wx {{systemInfo.version}} / sdk {{systemInfo.SDKVersion}} + playerPlugin: {{playerPluginVersion}} + playerSrc: {{playerSrc}} + playerAudioSrc: {{playerAudioSrc}} + playStatus: {{playStatus}} + + + Logclear + {{log}} + + + diff --git a/demo/miniprogram/pages/test-local-mjpg-player/player.wxss b/demo/miniprogram/pages/test-local-mjpg-player/player.wxss new file mode 100644 index 0000000..0dd5e40 --- /dev/null +++ b/demo/miniprogram/pages/test-local-mjpg-player/player.wxss @@ -0,0 +1,19 @@ +@import '../../common.wxss'; + +.player-container { + box-sizing: border-box; + position: relative; + width: 100%; + height: 420rpx; + background-color: black; +} + +.player-container image { + width: 100%; + height: 100%; +} + +.player-container canvas { + width: 100%; + height: 100%; +} diff --git a/demo/miniprogram/pages/test-mjpg-canvas/test.js b/demo/miniprogram/pages/test-mjpg-canvas/test.js new file mode 100644 index 0000000..bc57773 --- /dev/null +++ b/demo/miniprogram/pages/test-mjpg-canvas/test.js @@ -0,0 +1,261 @@ +const fileSystem = wx.getFileSystemManager(); + +Page({ + data: { + ctxReady: false, + frameNum: 0, + isPlaying: false, + }, + onLoad() { + // 渲染无关的尽量放这里 + this.userData = { + // 解析出来的帧列表 + frames: [], + + // 切换img用的 + imgIndex: -1, + timer: null, + + // 循环render用的 + renderCount: 0, + renderLogTime: 0, + }; + + // 通过 SelectorQuery 获取 Canvas 节点 + wx.createSelectorQuery() + .select('#canvas') + .fields({ + node: true, + size: true, + }) + .exec(this.init.bind(this)); + }, + onUnload() { + this.stop(); + }, + init(res) { + const canvas = res[0].node; + const width = res[0].width; + const height = res[0].height; + const dpr = wx.getSystemInfoSync().pixelRatio; + canvas.width = width * dpr; + canvas.height = height * dpr; + console.log('canvas', res[0], dpr, canvas.width, canvas.height); + + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + + const img = canvas.createImage(); + + Object.assign(this.userData, { + canvas, + dpr, + ctx, + img, + }); + this.setData({ ctxReady: true }); + }, + onImageLoad() { + if (this.userData.img.state !== 'loading') { + return; + } + // console.log('onImageLoad', this.userData.imgIndex, this.userData.img.width, this.userData.img.height); + this.userData.img.state = 'loaded'; + this.render(); + }, + onImageError() { + if (this.userData.img.state !== 'loading') { + return; + } + console.error('onImageError', this.userData.imgIndex); + this.userData.img.state = 'error'; + }, + async chooseImages() { + // 先停掉 + this.stop(); + + let files; + try { + const res = await wx.chooseMedia({ + count: 9, // 最多就是9 + mediaType: ['image'], + sourceType: ['album'], + }); + files = res.tempFiles; + console.log('chooseMedia success', files); + } catch (err) { + console.error('chooseMedia fail', err); + return; + } + + // 这个是追加 + this.userData.frames = this.userData.frames.concat(files); + this.setData({ + frameNum: this.userData.frames.length, + }); + console.log('chooseImages success, now', this.userData.frames.length); + }, + async chooseFile() { + // 先停掉 + this.stop(); + + // 文件里包括多个帧,清理之前的images + this.clearImages(); + + let file; + try { + const res = await wx.chooseMessageFile({ count: 1 }); + file = res.tempFiles[0]; + console.log('chooseMessageFile success', file); + } catch (err) { + console.error('chooseMessageFile fail', err); + return; + } + + const buffer = fileSystem.readFileSync(file.path); + const totalBase64 = wx.arrayBufferToBase64(buffer); + if (!/^\/9j/.test(totalBase64)) { + // 不是jpeg的base64 + console.error('invalid mjpg file'); + return; + } + + const tempArr = totalBase64.split('/9j'); + tempArr.shift(); // 第一个是空字符串 + + const frames = tempArr.map(str => ({ + base64: `data:image/jpeg;base64,/9j${str}`, + })); + this.userData.frames = frames; + this.setData({ + srcType: 'base64', + frameNum: this.userData.frames.length, + }); + console.log('chooseFile success, now', this.userData.frames.length); + }, + loadAllBase64() { + if (!this.userData.frames) { + return; + } + this.userData.frames.forEach((file) => { + if (file.base64 || !file.tempFilePath) { + return; + } + const res = fileSystem.readFileSync(file.tempFilePath); + file.base64 = `data:image/jpeg;base64,${wx.arrayBufferToBase64(res)}`; + }); + }, + clearImages() { + // 先停掉 + this.stop(); + + this.userData.frames = []; + this.setData({ + srcType: '', + frameNum: 0, + }); + }, + clearTimer() { + if (this.userData?.timer) { + clearInterval(this.userData.timer); + this.userData.timer = null; + } + }, + clearRenderData() { + if (this.userData?.img?.state) { + this.userData.img.state = ''; + delete this.userData.img.onload; + delete this.userData.img.onerror; + this.userData.img.src = ''; + } + this.userData.renderCount = 0; + this.userData.renderLogTime = 0; + }, + changeImgIndex(index) { + if (index === this.userData.imgIndex) { + return; + } + this.userData.imgIndex = index; + this.userData.img.state = 'loading'; + if (this.userData.srcType === 'filepath') { + this.userData.img.src = this.userData.frames[index].tempFilePath; + } else { + this.userData.img.src = this.userData.frames[index].base64; + } + }, + render() { + if (this.userData?.img?.state !== 'loaded') { + return; + } + this.userData.renderCount++; + + const { canvas, dpr, ctx, img } = this.userData; + const imgWidth = img.width * dpr; + const imgHeight = img.height * dpr; + const scaleX = canvas.width / imgWidth; + const scaleY = canvas.height / imgHeight; + const scale = Math.min(scaleX, scaleY); + const showWidth = imgWidth * scale; + const showHeight = imgHeight * scale; + const x = (canvas.width - showWidth) / 2 / dpr; + const y = (canvas.height - showHeight) / 2 / dpr; + + const now = Date.now(); + const needLog = now - this.userData.renderLogTime>= 1000; + if (needLog) { + this.userData.renderLogTime = now; + console.log( + 'drawImage', + `image ${this.userData.imgIndex}`, + `renderCount ${this.userData.renderCount}`, + 0, 0, imgWidth, imgHeight, x, y, showWidth, showHeight, + ); + } + ctx.drawImage( + img, + 0, 0, imgWidth, imgHeight, + x, y, showWidth, showHeight, + ); + }, + play({ currentTarget }) { + if (this.data.isPlaying || !this.userData.frames?.length) { + return; + } + + this.clearTimer(); + this.clearRenderData(); + + this.setData({ + isPlaying: true, + }); + + this.userData.srcType = currentTarget.dataset.srcType || 'base64'; + if (this.userData.srcType === 'base64') { + this.loadAllBase64(); + } + this.userData.img.onload = this.onImageLoad.bind(this); + this.userData.img.onerror = this.onImageError.bind(this); + this.changeImgIndex(0); + + const total = this.userData.frames.length; + this.userData.timer = setInterval(() => { + if (this.userData.img.state === 'loading') { + return; + } + const index = (this.userData.imgIndex + 1) % total; + this.changeImgIndex(index); + }, 50); + }, + stop() { + if (!this.data.isPlaying) { + return; + } + + this.clearTimer(); + this.clearRenderData(); + + this.setData({ + isPlaying: false, + }); + }, +}); diff --git a/demo/miniprogram/pages/test-mjpg-canvas/test.json b/demo/miniprogram/pages/test-mjpg-canvas/test.json new file mode 100644 index 0000000..7f90f97 --- /dev/null +++ b/demo/miniprogram/pages/test-mjpg-canvas/test.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "MJPG 测试页" +} diff --git a/demo/miniprogram/pages/test-mjpg-canvas/test.wxml b/demo/miniprogram/pages/test-mjpg-canvas/test.wxml new file mode 100644 index 0000000..a89f75e --- /dev/null +++ b/demo/miniprogram/pages/test-mjpg-canvas/test.wxml @@ -0,0 +1,20 @@ + + + + total frame: {{frameNum}} + + + + + + + + + + + + diff --git a/demo/miniprogram/pages/test-mjpg-canvas/test.wxss b/demo/miniprogram/pages/test-mjpg-canvas/test.wxss new file mode 100644 index 0000000..4f84dc3 --- /dev/null +++ b/demo/miniprogram/pages/test-mjpg-canvas/test.wxss @@ -0,0 +1,6 @@ +@import '../../common.wxss'; + +.mjpg-canvas, .mjpg-image { + width: 100%; + height: 360rpx; +} \ No newline at end of file diff --git a/demo/miniprogram/pages/test-p2p-player/player.js b/demo/miniprogram/pages/test-p2p-player/player.js index ede2d0b..91c2aba 100644 --- a/demo/miniprogram/pages/test-p2p-player/player.js +++ b/demo/miniprogram/pages/test-p2p-player/player.js @@ -32,16 +32,18 @@ Page({ playerPluginVersion: xp2pManager.P2PPlayerVersion, }); - const recordManager = getRecordManager(query.dirname); - this.userData.recordManager = recordManager; - this.setData({ - localRecordName: query.filename, - }); + if (query.dirname && query.filename) { + const recordManager = getRecordManager(query.dirname); + this.userData.recordManager = recordManager; + this.setData({ + localRecordName: query.filename, + }); + } if (this.data.localRecordName) { // 拉取本地录像 this.addLog('正在读取本地录像...'); - recordManager + this.userData.recordManager .readFile(this.data.localRecordName) .then((res) => { this.addLog(`读取本地录像成功, size: ${res.data.byteLength}`); @@ -84,17 +86,17 @@ Page({ livePlayerInfoStr: '', }); }, - onPlayerReady({ detail, currentTarget }) { - console.log('==== onPlayerReady', currentTarget.id, detail); - this.addLog(`==== onPlayerReady, id: ${currentTarget.id}, innerId: ${detail.playerInnerId}`); + onPlayerReady({ detail }) { + console.log('==== onPlayerReady', detail); + this.addLog('==== onPlayerReady'); this.setData({ playerReady: true, playerComp: detail.playerExport, playerCtx: detail.livePlayerContext, }); }, - onPlayerStartPull({ detail, currentTarget }) { - console.log('==== onPlayerStartPull', currentTarget.id, detail); + onPlayerStartPull({ detail }) { + console.log('==== onPlayerStartPull', detail); this.addLog('==== onPlayerStartPull'); if (!this.data.playStatus) { this.addLog('not playing'); @@ -110,6 +112,7 @@ Page({ this.addLog(`is ending, cache: ${cache}`); console.log('now info', livePlayerInfo); if (cache < 200) { + console.log('start pull and cache end, stop', livePlayerInfo); this.bindStop(); } return; @@ -122,8 +125,8 @@ Page({ // 模拟拉流 this.pullFileVideo(); }, - onPlayerClose({ detail, currentTarget }) { - console.log('==== onPlayerClose', currentTarget.id, detail); + onPlayerClose({ detail }) { + console.log('==== onPlayerClose', detail); const code = detail && detail.error && detail.error.code; this.addLog(`==== onPlayerClose, code: ${code}`); this.setData({ @@ -136,8 +139,8 @@ Page({ }); } }, - onPlayerError({ detail, currentTarget }) { - console.error('==== onPlayerError', currentTarget.id, detail); + onPlayerError({ detail }) { + console.error('==== onPlayerError', detail); const code = detail && detail.error && detail.error.code; this.addLog(`==== onPlayerError, code: ${code}`); this.setData({ @@ -146,10 +149,11 @@ Page({ playerCtx: null, playStatus: '', }); + this.bindDestroyPlayer(); + if (code === 'WECHAT_SERVER_ERROR') { xp2pManager.needResetLocalServer = true; this.addLog(`set needResetLocalServer ${xp2pManager.needResetLocalServer}`); - this.bindDestroyPlayer(); } wx.showModal({ content: `player错误: ${code}`, @@ -203,8 +207,8 @@ Page({ console.log('==== onLivePlayerStateChange', detail.code, detail); } }, - onLivePlayerNetStatusChange({ detail }) { - // console.log('onLivePlayerNetStatusChange', detail); + onLivePlayerNetStatus({ detail }) { + // console.log('onLivePlayerNetStatus', detail); if (!detail.info) { return; } @@ -218,6 +222,9 @@ Page({ livePlayerInfo[key] = detail.info[key]; } } + if (livePlayerInfo.videoCache> 0 || livePlayerInfo.audioCache> 0) { + livePlayerInfo.hasCacheData = true; + } this.setData({ livePlayerInfoStr: ( detail.info @@ -229,10 +236,11 @@ Page({ : '' ), }); - if (typeof detail.info.videoCache === 'number' && this.data.playStatus === 'ending') { + if (typeof detail.info.audioCacheThreshold === 'number' && this.data.playStatus === 'ending' && livePlayerInfo.hasCacheData) { // 在播cache - const cache = Math.max(detail.info.videoCache, detail.info.audioCache); + const cache = Math.max(livePlayerInfo.videoCache, livePlayerInfo.audioCache); if (cache < 200) { + console.log('net status and cache end, stop', livePlayerInfo); this.bindStop(); } } @@ -317,26 +325,31 @@ Page({ }, }); }, - loopWrite(data, offset = 0, addChunkCBK = null, chunkSize, chunkInterval) { + loopWrite(data, offset = 0, addChunkCBK = null, chunkSize, chunkInterval, loopCount = 1) { if (!this.data.canWrite) { // 不能写 return; } if (offset>= data.byteLength) { - this.addLog('loopWrite end'); - this.data.playerComp.finishMedia(); - return; + loopCount--; + this.addLog(`loopWrite end, ${loopCount} left`); + if (loopCount> 0) { + offset = 0; + } else { + this.data.playerComp.finishMedia(); + return; + } } const chunkLen = Math.min(data.byteLength - offset, chunkSize); const videoData = data.slice(offset, offset + chunkLen); addChunkCBK && addChunkCBK(videoData); setTimeout(() => { - this.loopWrite(data, offset + chunkLen, addChunkCBK, chunkSize, chunkInterval); + this.loopWrite(data, offset + chunkLen, addChunkCBK, chunkSize, chunkInterval, loopCount); }, chunkInterval); }, pullFileVideo() { - const chunkSize = 200 * 1024; - const chunkInterval = 200; + const chunkSize = 10 * 1024; + const chunkInterval = 30; if (this.data.fileData) { this.addLog('start loopWrite'); this.loopWrite( diff --git a/demo/miniprogram/pages/test-p2p-player/player.json b/demo/miniprogram/pages/test-p2p-player/player.json index 00eb9c7..031ae1c 100644 --- a/demo/miniprogram/pages/test-p2p-player/player.json +++ b/demo/miniprogram/pages/test-p2p-player/player.json @@ -1,4 +1,5 @@ { + "navigationBarTitleText": "P2P-Player 测试页", "usingComponents": { "p2p-player": "plugin://wechat-p2p-player/p2p-player" } diff --git a/demo/miniprogram/pages/test-p2p-player/player.wxml b/demo/miniprogram/pages/test-p2p-player/player.wxml index c668cf5..2b716b7 100644 --- a/demo/miniprogram/pages/test-p2p-player/player.wxml +++ b/demo/miniprogram/pages/test-p2p-player/player.wxml @@ -13,7 +13,7 @@ bind:playerError="onPlayerError" bind:error="onLivePlayerError" bind:statechange="onLivePlayerStateChange" - bind:netstatus="onLivePlayerNetStatusChange" + bind:netstatus="onLivePlayerNetStatus" /> diff --git a/demo/miniprogram/pages/test-p2p-player/player.wxss b/demo/miniprogram/pages/test-p2p-player/player.wxss index 0c7d326..a67dc71 100644 --- a/demo/miniprogram/pages/test-p2p-player/player.wxss +++ b/demo/miniprogram/pages/test-p2p-player/player.wxss @@ -13,7 +13,7 @@ height: 100% !important; } -.video-info { +.player-container .video-info { position: absolute; left: 20rpx; top: 20rpx; diff --git a/demo/miniprogram/pages/test-p2p-pusher/pusher.js b/demo/miniprogram/pages/test-p2p-pusher/pusher.js index 26b96b7..e54f375 100644 --- a/demo/miniprogram/pages/test-p2p-pusher/pusher.js +++ b/demo/miniprogram/pages/test-p2p-pusher/pusher.js @@ -239,8 +239,8 @@ Page({ // console.log('==== onLivePusherStateChange', detail.code, detail); } }, - onLivePusherNetStatusChange({ detail }) { - // console.log('onLivePusherNetStatusChange', detail); + onLivePusherNetStatus({ detail }) { + // console.log('onLivePusherNetStatus', detail); if (!detail.info) { return; } diff --git a/demo/miniprogram/pages/test-p2p-pusher/pusher.wxml b/demo/miniprogram/pages/test-p2p-pusher/pusher.wxml index 893141a..d33c5c1 100644 --- a/demo/miniprogram/pages/test-p2p-pusher/pusher.wxml +++ b/demo/miniprogram/pages/test-p2p-pusher/pusher.wxml @@ -20,7 +20,7 @@ bind:pusherError="onPusherError" bind:error="onLivePusherError" bind:statechange="onLivePusherStateChange" - bind:netstatus="onLivePusherNetStatusChange" + bind:netstatus="onLivePusherNetStatus" /> diff --git a/demo/miniprogram/pages/test-video/test.js b/demo/miniprogram/pages/test-video/test.js index 9b2d7d0..b0c2e9d 100644 --- a/demo/miniprogram/pages/test-video/test.js +++ b/demo/miniprogram/pages/test-video/test.js @@ -1,3 +1,8 @@ +import { getRecordManager } from '../../lib/recordManager'; + +const videoManager = getRecordManager('videos'); +const fileSystem = wx.getFileSystemManager(); + Page({ data: { // inputSrc: 'https://zylcb.iotvideo.tencentcs.com/timeshift/live/21f2453c-9896-4617-a085-1ea77a28d74d/timeshift.m3u8?starttime_epoch=1636682322&endtime_epoch=1636682785&t=618f9142&us=a43e598a6dbf00a47622f3ec34801d88&sign=f1d0f360dfe0e3a1d7301f8b2e926349', @@ -26,6 +31,44 @@ Page({ inputSrc: e.detail.value, }); }, + async bindChoose(e) { + const { from } = e.currentTarget.dataset; + let file; + if (from === 'message') { + try { + const res = await wx.chooseMessageFile({ count: 1 }); + file = res.tempFiles[0]; + console.log('chooseMessageFile success', file); + } catch (err) { + console.error('chooseMessageFile fail', err); + this.setData({ + errMsg: err.errMsg, + }); + return; + } + } else { + try { + const res = await wx.chooseMedia({ count: 1, mediaType: ['video'], sourceType: ['album'] }); + file = res.tempFiles[0]; + console.log('chooseMedia success', file); + } catch (err) { + console.error('chooseMedia fail', err); + this.setData({ + errMsg: err.errMsg, + }); + return; + } + } + + videoManager.prepareDir(); + const fileName = file.name || `noname.${Date.now()}.mp4`; + const filePath = `${videoManager.baseDir}/${fileName}`; + fileSystem.saveFileSync(file.path || file.tempFilePath, filePath); + + this.setData({ + inputSrc: filePath, + }); + }, bindSetSrc() { this.setData({ src: this.data.inputSrc, diff --git a/demo/miniprogram/pages/test-video/test.wxml b/demo/miniprogram/pages/test-video/test.wxml index 2daf96f..7ec2aa9 100644 --- a/demo/miniprogram/pages/test-video/test.wxml +++ b/demo/miniprogram/pages/test-video/test.wxml @@ -19,6 +19,10 @@ + + + + diff --git a/demo/miniprogram/pages/xp2p-demo-1vN/demo.js b/demo/miniprogram/pages/xp2p-demo-1vN/demo.js index 0a0176d..ab8b46d 100644 --- a/demo/miniprogram/pages/xp2p-demo-1vN/demo.js +++ b/demo/miniprogram/pages/xp2p-demo-1vN/demo.js @@ -49,17 +49,17 @@ Page({ } }); }, - onPlayError({ detail, currentTarget }) { - console.log('demo: onPlayError', currentTarget.id, detail); + onPlayError({ detail }) { + console.log('demo: onPlayError', detail); const { errMsg, errDetail, isFatalError } = detail; wx.showModal({ - content: `${currentTarget.id}: ${errMsg || '播放失败'}\n${(errDetail && errDetail.msg) || ''}`, // 换行在开发者工具中无效,真机正常 + content: `${errMsg || '播放失败'}\n${(errDetail && errDetail.msg) || ''}`, // 换行在开发者工具中无效,真机正常 showCancel: false, complete: () => { if (isFatalError) { - // demo简单点,直接退出,注意 onUnload 时可能需要reset插件 - // 如果不想退出,在这里reset插件(如果需要的话),然后重新创建player组件 - !this.hasExited && wx.navigateBack(); + // 致命错误,需要reset的全部reset + xp2pManager.checkReset(); + this.data.player.reset(); } }, }); diff --git a/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxml b/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxml index afe424e..d15e7c9 100644 --- a/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxml +++ b/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxml @@ -1,38 +1,35 @@ - - + + - - - - ... - + + + + + ... diff --git a/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxss b/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxss index c441c4f..49e4d0e 100644 --- a/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxss +++ b/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxss @@ -7,4 +7,5 @@ page { top: 20rpx; right: 20rpx; color: #fff; + z-index: 10; } \ No newline at end of file diff --git a/demo/miniprogram/pages/xp2p-multiplayers/demo.js b/demo/miniprogram/pages/xp2p-multiplayers/demo.js index 6cef3ad..2deedb9 100644 --- a/demo/miniprogram/pages/xp2p-multiplayers/demo.js +++ b/demo/miniprogram/pages/xp2p-multiplayers/demo.js @@ -96,10 +96,4 @@ Page({ }, }); }, - onPlayerSystemPermissionDenied({ detail }) { - wx.showModal({ - content: `systemPermissionDenied\n${detail.errMsg}`, - showCancel: false, - }); - }, }); diff --git a/demo/miniprogram/pages/xp2p-multiplayers/demo.wxml b/demo/miniprogram/pages/xp2p-multiplayers/demo.wxml index 4e22da3..d701497 100644 --- a/demo/miniprogram/pages/xp2p-multiplayers/demo.wxml +++ b/demo/miniprogram/pages/xp2p-multiplayers/demo.wxml @@ -1,8 +1,8 @@ - ==== {{item.playerId}}: {{item.mode}} + ==== {{item.playerId}}: {{item.p2pMode}} \ No newline at end of file diff --git a/demo/miniprogram/pages/xp2p-records/records.js b/demo/miniprogram/pages/xp2p-records/records.js index 14b7a54..61c925f 100644 --- a/demo/miniprogram/pages/xp2p-records/records.js +++ b/demo/miniprogram/pages/xp2p-records/records.js @@ -1,21 +1,31 @@ -import { isMP4 } from '../../utils'; +import { isFLV, isMP4, isMJPG } from '../../utils'; import { getRecordManager } from '../../lib/recordManager'; +const processFileItem = (item) => { + if (item) { + item.isFLV = isFLV(item.fileName); + item.isMP4 = isMP4(item.fileName); + item.isMJPG = isMJPG(item.fileName); + } + return item; +}; + Page({ data: { - recordManager: null, baseDir: '', isRefreshing: false, recordList: null, totalBytes: NaN, - showSendDoc: wx.getSystemInfoSync().platform !== 'devtools', // 开发者工具可以直接保存到磁盘,不用显示发送文档 + isDevTools: wx.getSystemInfoSync().platform === 'devtools', // 开发者工具可以直接保存到磁盘,不用显示发送文档 }, onLoad(query) { console.log('records: onLoad', query); - const recordManager = getRecordManager(query.name); + if (!query?.name) { + return; + } + this.recordManager = getRecordManager(query.name); this.setData({ - recordManager, - baseDir: recordManager.baseDir, + baseDir: this.recordManager.baseDir, }); this.getRecordList(); }, @@ -25,20 +35,22 @@ Page({ } this.setData({ isRefreshing: true }); - const files = this.data.recordManager.getSavedRecordList(); - const recordList = files.map(fileName => ({ fileName, size: NaN, isMP4: isMP4(fileName) })); + const files = this.recordManager.getSavedRecordList(); + const recordList = files.map(fileName => processFileItem({ + fileName, + size: NaN, + })); this.setData({ recordList, totalBytes: files.length> 0 ? NaN : 0, }); if (files.length> 0) { - const pArr = files.map(fileName => this.data.recordManager.getFileInfo(fileName)); + const pArr = files.map(fileName => this.recordManager.getFileInfo(fileName)); try { const infos = await Promise.all(pArr); const total = infos.reduce((prev, { size }) => (prev + size), 0); this.setData({ - // recordList: recordList.map((baseInfo, i) => ({ ...baseInfo, ...infos[i] })), recordList: infos.map((info, i) => ({ ...recordList[i], ...info })), totalBytes: total, }); @@ -47,12 +59,46 @@ Page({ this.setData({ isRefreshing: false }); }, + + async addFile() { + let file; + try { + const res = await wx.chooseMessageFile({ + count: 1, + type: 'file', + extension: ['flv', 'mjpg', 'mp4', 'aac'], + }); + file = res.tempFiles[0]; + console.log('choose file res', file); + if (!file?.size) { + wx.showToast({ + title: 'file empty', + icon: 'error', + }); + return; + } + } catch (err) { + console.error('choose file fail', err); + return; + } + try { + const addRes = await this.recordManager.addFile(file.name, file.path); + console.log('add file res', addRes); + wx.showToast({ + title: '添加成功', + icon: 'none', + }); + this.getRecordList(); + } catch (err) { + console.error('add file fail', err); + } + }, removeAllRecords() { if (this.data.isRefreshing) { return; } - this.data.recordManager.removeSavedRecordList(); + this.recordManager.removeSavedRecordList(); this.setData({ recordList: [], totalBytes: 0, @@ -68,12 +114,27 @@ Page({ }); return; } - wx.navigateTo({ - url: [ - `/pages/test-p2p-player/player?dirname=${encodeURIComponent(this.data.recordManager.name)}`, - `&filename=${encodeURIComponent(fileRes.fileName)}`, - ].join(''), - }); + if (fileRes.isFLV) { + wx.navigateTo({ + url: [ + `/pages/test-p2p-player/player?dirname=${encodeURIComponent(this.recordManager.name)}`, + `&filename=${encodeURIComponent(fileRes.fileName)}`, + ].join(''), + }); + } else if (fileRes.isMJPG) { + wx.navigateTo({ + url: [ + `/pages/test-local-mjpg-player/player?dirname=${encodeURIComponent(this.recordManager.name)}`, + `&filename=${encodeURIComponent(fileRes.fileName)}`, + ].join(''), + }); + } else { + console.error('can not play record', fileRes); + wx.showToast({ + title: '不支持的文件类型', + icon: 'none', + }); + } }, renameMP4(e) { const { index } = e.currentTarget.dataset; @@ -87,13 +148,13 @@ Page({ } try { const newFileName = `${fileRes.fileName}.mp4`; - this.data.recordManager.renameFile(fileRes.fileName, newFileName); + this.recordManager.renameFile(fileRes.fileName, newFileName); wx.showToast({ title: '重命名成功', icon: 'none', }); fileRes.fileName = newFileName; - fileRes.isMP4 = isMP4(fileRes.fileName); + processFileItem(fileRes); this.setData({ recordList: [...this.data.recordList], }); @@ -105,11 +166,11 @@ Page({ }); } }, - async saveVideoToAlbum(e) { + async saveToAlbum(e) { const { index } = e.currentTarget.dataset; const fileRes = this.data.recordList[index]; try { - await this.data.recordManager.saveVideoToAlbum(fileRes.fileName); + await this.recordManager.saveToAlbum(fileRes.fileName); wx.showModal({ title: '已保存到相册', showCancel: false, @@ -126,15 +187,28 @@ Page({ }); } }, - async sendDocument(e) { + saveFileInDevTools(e) { + const { index } = e.currentTarget.dataset; + const fileRes = this.data.recordList[index]; + // 开发者工具里什么都可以保存,注意文件后缀 + wx.saveImageToPhotosAlbum({ + filePath: `${this.recordManager.baseDir}/${fileRes.fileName}`, + success: (res) => { + console.log(res); + }, + fail: (res) => { + console.error(res); + } + }); + }, + async sendFile(e) { const { index } = e.currentTarget.dataset; const fileRes = this.data.recordList[index]; try { - await this.data.recordManager.sendDocument(fileRes.fileName); - // 不用弹框,用户能看到新开页面 + await this.recordManager.sendFile(fileRes.fileName); } catch (err) { wx.showModal({ - title: '打开文件失败', + title: '发送失败', content: err.errMsg || '', showCancel: false, }); @@ -144,7 +218,7 @@ Page({ const { index } = e.currentTarget.dataset; const fileRes = this.data.recordList[index]; try { - await this.data.recordManager.removeFile(fileRes.fileName); + await this.recordManager.removeFile(fileRes.fileName); wx.showToast({ title: '删除成功', icon: 'none', diff --git a/demo/miniprogram/pages/xp2p-records/records.wxml b/demo/miniprogram/pages/xp2p-records/records.wxml index 87555bd..2ccdfa0 100644 --- a/demo/miniprogram/pages/xp2p-records/records.wxml +++ b/demo/miniprogram/pages/xp2p-records/records.wxml @@ -5,10 +5,11 @@ · flv文件在Android系统可以保存到相册,但是iOS系统不支持 - + baseDir: {{baseDir}} + @@ -17,11 +18,12 @@ {{item.fileName}} {{item.size}} Bytes - 播放 + 播放 - 发送 - 保存到相册 - + 发送 + 保存 + 保存到相册 + diff --git a/demo/miniprogram/pages/xp2p-singleplayer/demo.js b/demo/miniprogram/pages/xp2p-singleplayer/demo.js index fe81c3b..3a10bea 100644 --- a/demo/miniprogram/pages/xp2p-singleplayer/demo.js +++ b/demo/miniprogram/pages/xp2p-singleplayer/demo.js @@ -6,7 +6,7 @@ const xp2pManager = getXp2pManager(); Page({ data: { // 这是onLoad时就固定的 - mode: '', + p2pMode: '', targetId: '', flvUrl: '', productId: '', @@ -38,7 +38,7 @@ Page({ return; } - if (newData.mode === 'ipc') { + if (newData.p2pMode === 'ipc') { newData.playerTitle = `${newData.productId}/${newData.deviceName}`; } @@ -80,10 +80,4 @@ Page({ }, }); }, - onPlayerSystemPermissionDenied({ detail }) { - wx.showModal({ - content: `systemPermissionDenied\n${detail.errMsg}`, - showCancel: false, - }); - }, }); diff --git a/demo/miniprogram/pages/xp2p-singleplayer/demo.wxml b/demo/miniprogram/pages/xp2p-singleplayer/demo.wxml index 578f5e0..e86beba 100644 --- a/demo/miniprogram/pages/xp2p-singleplayer/demo.wxml +++ b/demo/miniprogram/pages/xp2p-singleplayer/demo.wxml @@ -1,27 +1,26 @@ - + {{playerTitle || targetId}} \ No newline at end of file diff --git a/demo/miniprogram/project.config.json b/demo/miniprogram/project.config.json index 8802279..8fb69f9 100644 --- a/demo/miniprogram/project.config.json +++ b/demo/miniprogram/project.config.json @@ -1,8 +1,7 @@ { "appid": "wx9e8fbc98ceac2628", - "projectname": "iotvideo-xp2p-github", + "projectname": "iotvideo-xp2p-demo", "compileType": "miniprogram", - "libVersion": "2.19.3", "setting": { "urlCheck": false, "es6": true, @@ -34,12 +33,7 @@ "packNpmRelationList": [], "minifyWXSS": true, "minifyWXML": true, - "ignoreUploadUnusedFiles": true, - "lazyloadPlaceholderEnable": false, - "disableUseStrict": false, - "showES6CompileOption": false, - "useCompilerPlugins": false, - "useStaticServer": true + "ignoreUploadUnusedFiles": true }, "simulatorType": "wechat", "simulatorPluginLibVersion": {}, diff --git a/demo/miniprogram/project.private.config.json b/demo/miniprogram/project.private.config.json new file mode 100644 index 0000000..9fdbafd --- /dev/null +++ b/demo/miniprogram/project.private.config.json @@ -0,0 +1,7 @@ +{ + "projectname": "iotvideo-xp2p-github", + "setting": { + "compileHotReLoad": true + }, + "description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html" +} \ No newline at end of file diff --git a/demo/miniprogram/utils.js b/demo/miniprogram/utils.js index 77b1ac5..fddb5f8 100644 --- a/demo/miniprogram/utils.js +++ b/demo/miniprogram/utils.js @@ -26,7 +26,7 @@ export function compareVersion(ver1, ver2) { return 0; } -const sysInfo = wx.getSystemInfoSync(); +export const sysInfo = wx.getSystemInfoSync(); export const canUseP2PIPCMode = compareVersion(sysInfo.SDKVersion, '2.19.3')>= 0; export const canUseP2PServerMode = compareVersion(sysInfo.SDKVersion, '2.20.2')>= 0; @@ -64,14 +64,14 @@ export const getPlayerProperties = (cfg, opts) => { } let flvUrl = ''; - if (cfgData.mode === 'ipc') { + if (cfgData.p2pMode === 'ipc') { flvUrl = `http://XP2P_INFO.xnet/ipc.p2p.com/ipc.flv?${cfgData.liveParams}`; } else { flvUrl = cfgData.flvUrl; } return { - mode: cfgData.mode, + p2pMode: cfgData.p2pMode, targetId: cfgData.targetId, flvUrl: flvUrl || '', productId: cfgData.productId || '', @@ -103,4 +103,101 @@ export const checkAuthorize = (scope) => }); }); +export const isFLV = filename => /\.flv$/i.test(filename); export const isMP4 = filename => /\.mp4$/i.test(filename); +export const isMJPG = filename => /\.mjpg$/i.test(filename); + +export function stringToUint8Array(str) { + const encodedString = unescape(encodeURIComponent(str || '')); + const unit8Arr = []; + const len = encodedString.length; + for (let i = 0; i < len; i++) { + unit8Arr.push(encodedString.charAt(i).charCodeAt(0)); + } + return new Uint8Array(unit8Arr); +} + +export function stringToArrayBuffer(str) { + return stringToUint8Array(str).buffer; +} + +export function uint8ArrayToString(unit8Arr) { + const encodedString = String.fromCharCode.apply(null, unit8Arr); + const decodedString = decodeURIComponent(encodeURIComponent(encodedString)); + return decodedString; +} + +export function arrayBufferToString(buffer, offset = undefined, len = undefined) { + return uint8ArrayToString(new Uint8Array(buffer, offset, len)); +} + +export async function snapshotAndSave({ snapshot }) { + // 先检查权限 + try { + await checkAuthorize('scope.writePhotosAlbum'); + } catch (err) { + console.log('snapshot checkAuthorize fail', err); + const modalRes = await wx.showModal({ + title: '', + content: '拍照需要您授权小程序访问相册', + confirmText: '去授权', + }); + if (modalRes.confirm) { + wx.openSetting(); + } + return; + } + + let timer; + const endSnapshot = (params) => { + if (timer) { + clearTimeout(timer); + timer = null; + } + wx.hideLoading(); + wx.showModal({ showCancel: false, ...params }); + }; + + timer = setTimeout(() => { + console.error('snapshot timeout'); + endSnapshot({ + title: '拍照超时', + }); + }, 5000); + + wx.showLoading({ + title: '拍照中', + }); + + console.log('do snapshot'); + let snapshotRes = null; + try { + snapshotRes = await snapshot(); + console.log('snapshot success', snapshotRes); + } catch (err) { + console.error('snapshot fail', err); + endSnapshot({ + title: '拍照失败', + content: err.errMsg, + }); + return; + } + + console.log('do saveImageToPhotosAlbum'); + try { + const saveRes = await wx.saveImageToPhotosAlbum({ + filePath: snapshotRes.tempImagePath, + }); + console.log('saveImageToPhotosAlbum success', saveRes); + endSnapshot({ + isSuccess: true, + title: '已保存到相册', + }); + } catch (err) { + console.log('saveImageToPhotosAlbum fail', err); + endSnapshot({ + title: '保存到相册失败', + content: ~err.errMsg.indexOf('auth deny') ? '请授权小程序访问相册' : err.errMsg, + }); + } +} diff --git a/pic/miniprogram/demo-qrcode-1.3.0.png b/pic/miniprogram/demo-qrcode-1.3.0.png new file mode 100644 index 0000000..2fe8bb4 Binary files /dev/null and b/pic/miniprogram/demo-qrcode-1.3.0.png differ diff --git a/pic/miniprogram/demo-qrcode.png b/pic/miniprogram/demo-qrcode.png index 2fe8bb4..107bf49 100644 Binary files a/pic/miniprogram/demo-qrcode.png and b/pic/miniprogram/demo-qrcode.png differ

AltStyle によって変換されたページ (->オリジナル) /