From 3b78d40588b4fdcd5228f094067c4260ec57d158 Mon Sep 17 00:00:00 2001 From: sunnywwcao Date: 2022年9月27日 11:33:27 +0800 Subject: [PATCH 1/2] feat(demo): quick player --- demo/miniprogram/app.js | 19 + .../iot-p2p-common-player-quick/common.js | 87 + .../iot-p2p-common-player-quick/player.js | 1487 +++++++++++++++++ .../iot-p2p-common-player-quick/player.json | 3 + .../iot-p2p-common-player-quick/player.wxml | 80 + .../iot-p2p-common-player-quick/player.wxss | 65 + .../iot-p2p-common-player-quick/stat.js | 179 ++ demo/miniprogram/lib/xp2pManager.js | 132 +- 8 files changed, 2044 insertions(+), 8 deletions(-) create mode 100644 demo/miniprogram/components/iot-p2p-common-player-quick/common.js create mode 100644 demo/miniprogram/components/iot-p2p-common-player-quick/player.js create mode 100644 demo/miniprogram/components/iot-p2p-common-player-quick/player.json create mode 100644 demo/miniprogram/components/iot-p2p-common-player-quick/player.wxml create mode 100644 demo/miniprogram/components/iot-p2p-common-player-quick/player.wxss create mode 100644 demo/miniprogram/components/iot-p2p-common-player-quick/stat.js diff --git a/demo/miniprogram/app.js b/demo/miniprogram/app.js index c870791..e70db6d 100644 --- a/demo/miniprogram/app.js +++ b/demo/miniprogram/app.js @@ -18,4 +18,23 @@ App({ }; } }, + + preInitP2P() { + if (this.xp2pManager) { + // 已经加载过了 + this.logger.log('app: preload xp2pManager, already has xp2pManager'); + return Promise.resolve(this.xp2pManager); + } + + return new Promise((resolve, reject) => { + this.logger.log('app: preload xp2pManager'); + require.async('./libs/xp2pManager.js').then(pkg => { + this.logger.log(`app: preload xp2pManager success, now xp2pManager ${!!this.xp2pManager}`); + resolve(this.xp2pManager); + }).catch(({mod, errMsg}) => { + this.logger.error(`app: preload xp2pManager fail, path: ${mod}, ${errMsg}`); + reject({mod, errMsg}); + }); + }); + }, }); diff --git a/demo/miniprogram/components/iot-p2p-common-player-quick/common.js b/demo/miniprogram/components/iot-p2p-common-player-quick/common.js new file mode 100644 index 0000000..95ccb12 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-common-player-quick/common.js @@ -0,0 +1,87 @@ +// 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', + P2PLocalError: 'P2PLocalError', + P2PLocalNATChanged: 'P2PLocalNATChanged', + + ServiceError: 'ServiceError', +}; + +export const StreamStateEnum = { + StreamIdle: 'StreamIdle', + StreamWaitPull: 'StreamWaitPull', + StreamReceivePull: 'StreamReceivePull', + StreamLocalServerError: 'StreamLocalServerError', + 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.P2PLocalError]: 'P2PLocalError', + [P2PStateEnum.P2PLocalNATChanged]: '本地NAT发生变化', + [P2PStateEnum.ServiceError]: '连接失败或断开', + + [StreamStateEnum.StreamWaitPull]: '加载中...', + [StreamStateEnum.StreamReceivePull]: '加载中...', + [StreamStateEnum.StreamLocalServerError]: '本地HttpServer错误', + [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.StreamStartError, + StreamStateEnum.StreamHttpStatusError, + StreamStateEnum.StreamError, +].indexOf(streamState)>= 0; diff --git a/demo/miniprogram/components/iot-p2p-common-player-quick/player.js b/demo/miniprogram/components/iot-p2p-common-player-quick/player.js new file mode 100644 index 0000000..5f9d167 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-common-player-quick/player.js @@ -0,0 +1,1487 @@ +/* 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'; + +// 覆盖 console +const app = getApp(); +const oriConsole = app.console; +const console = app.logger || oriConsole; + +const xp2pManager = getXp2pManager(); +const { XP2PServiceEventEnum, XP2PEventEnum, XP2PNotify_SubType } = xp2pManager; + +const recordManager = getRecordManager('records'); + +const cacheIgnore = 500; + +let playerSeq = 0; + +Component({ + behaviors: ['wx://component-export'], + properties: { + 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, + }, + muted: { + type: Boolean, + value: false, + }, + orientation: { + type: String, // vertical / horizontal + value: 'vertical', + }, + // 以下是自己的属性 + p2pMode: { + type: String, // ipc / server + value: '', + }, + targetId: { + type: String, + value: '', + }, + flvUrl: { + type: String, + value: '', + }, + autoReplay: { + type: Boolean, + value: false, + }, + parseLivePlayerInfo: { + type: Boolean, + value: false, + }, + cacheThreshold: { + type: Number, + value: 0, + }, + superMuted: { + type: Boolean, + value: false, + }, + showControlRightBtns: { + type: Boolean, + value: true, + }, + showLog: { + type: Boolean, + value: true, + }, + // 以下 ipc 模式用 + productId: { + type: String, + value: '', + }, + deviceName: { + type: String, + value: '', + }, + xp2pInfo: { + type: String, + value: '', + }, + liveStreamDomain: { + type: String, + value: '', + }, + // 以下 server 模式用 + codeUrl: { + type: String, + value: '', + }, + // 不能直接传函数,只能在数据中包含函数,所以放在 checkFunctions 里 + /* + checkFunctions: { + checkIsFlvValid: ({ filename, params }) => boolean, + checkCanStartStream: ({ filename, params }) => Promise, + } + */ + checkFunctions: { + type: Object, + value: {}, + }, + // 以下仅供调试,正式组件不需要 + onlyp2p: { + type: Boolean, + value: false, + }, + }, + data: { + innerId: '', + p2pPlayerVersion: xp2pManager.P2PPlayerVersion, + xp2pVersion: xp2pManager.XP2PVersion, + xp2pUUID: xp2pManager.uuid, + + // page相关 + pageHideTimestamp: 0, + + // 这是attached时就固定的 + streamExInfo: null, + canUseP2P: false, + needPlayer: false, + + // 当前flv + flvFile: '', + flvFilename: '', + flvParams: '', + streamType: '', + + // player状态 + hasPlayer: false, // needPlayer时才有效,出错销毁时设为false + autoPlay: false, + playerId: '', // 这是 p2p-player 组件的id,不是自己的id + playerState: PlayerStateEnum.PlayerIdle, + playerDenied: false, + playerMsg: '', + playerPaused: false, // false / true / 'stopped' + needPauseStream: false, // 为true时不addChunk + firstChunkDataInPaused: null, + acceptLivePlayerEvents: { + // 太多事件log了,只接收这3个 + error: true, + statechange: true, + netstatus: true, + // audiovolumenotify: true, + }, + + // stream状态 + streamState: StreamStateEnum.StreamIdle, + playing: false, + + // 这些是播放相关信息 + livePlayerInfoStr: '', + + // debug用 + showDebugInfo: false, + isSlow: false, + isRecording: false, + + // 播放结果 + playResultStr: '', + idrResultStr: '', + + // 控件 + 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; + this.console.log(`[${this.data.innerId}]`, '==== page show, hideTime', hideTime); + this.setData({ + pageHideTimestamp: 0, + }); + }, + hide() { + this.console.log(`[${this.data.innerId}]`, '==== page hide'); + this.setData({ + pageHideTimestamp: Date.now(), + }); + }, + }, + lifetimes: { + created() { + // 在组件实例刚刚被创建时执行 + playerSeq++; + this.setData({ innerId: `common-player-quick-${playerSeq}` }); + + // 渲染无关,不放在data里,以免影响性能 + this.userData = { + isDetached: false, + playerComp: null, + playerCtx: null, + chunkTime: 0, + chunkCount: 0, + totalBytes: 0, + livePlayerInfo: null, + fileObj: null, + needFixSoundMode: false, + }; + + this.console = console; + }, + attached() { + // 在组件实例进入页面节点树时执行 + if (!this.properties.showLog) { + // 不显示log + this.console = { + log: () => undefined, + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + } + this.console.log(`[${this.data.innerId}]`, '==== attached', this.id, { + p2pMode: this.properties.p2pMode, + targetId: this.properties.targetId, + flvUrl: this.properties.flvUrl, + mode: this.properties.mode, + }); + + 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; + const playerId = `${this.data.innerId}-player`; // 这是 p2p-player 组件的id,不是自己的id + let playerState; + let playerMsg = ''; + if (canUseP2P) { + playerState = PlayerStateEnum.PlayerIdle; + } else { + playerState = PlayerStateEnum.PlayerError; + playerMsg = isP2PModeValid ? '您的微信基础库版本过低,请升级后再使用' : `无效的p2pType: ${this.properties.p2pMode}`; + } + + const { acceptLivePlayerEvents } = this.data; + acceptLivePlayerEvents.netstatus = this.properties.parseLivePlayerInfo; + + // 统计用 + this.stat = new PlayStat({ + innerId: this.data.innerId, + 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, + liveStreamDomain: this.properties.liveStreamDomain, + codeUrl: this.properties.codeUrl, + }, + canUseP2P, + needPlayer, + hasPlayer, + playerId, + playerState, + playerMsg, + acceptLivePlayerEvents, + }); + + if (!canUseP2P) { + return; + } + this.createPlayer(); + }, + ready() { + // 在组件在视图层布局完成后执行 + }, + detached() { + // 在组件实例被从页面节点树移除时执行 + this.console.log(`[${this.data.innerId}]`, '==== detached'); + this.userData.isDetached = true; + this.stopAll(); + this.console.log(`[${this.data.innerId}]`, '==== detached end'); + }, + error() { + // 每当组件方法抛出错误时执行 + }, + }, + export() { + 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: { + getPlayerMessage(overrideData) { + if (!this.data.canUseP2P) { + return '您的微信基础库版本过低,请升级后再使用'; + } + + if (!this.properties.targetId) { + return 'targetId为空'; + } + + const realData = { + playerState: this.data.playerState, + streamState: this.data.streamState, + ...overrideData, + }; + + let msg = ''; + if (realData.playerState === PlayerStateEnum.PlayerReady) { + // PlayerReady 之后显示 streamState 对应状态 + msg = totalMsgMap[realData.streamState] || ''; + } else { + // PlayerReady 之前显示 playerState 对应状态 + msg = totalMsgMap[realData.playerState] || ''; + } + return msg; + }, + // 包一层,方便更新 playerMsg + changeState(newData, callback) { + this.stat.addStateTimestamp(newData.playerState); + this.stat.addStateTimestamp(newData.streamState); + + if (newData.hasPlayer === false) { + this.userData.playerComp = null; + this.userData.playerCtx = null; + } + const oldPlayerState = this.data.playerState; + const oldStreamState = this.data.streamState; + this.setData({ + ...newData, + playerMsg: typeof newData.playerMsg === 'string' ? newData.playerMsg : this.getPlayerMessage(newData), + }, callback); + if (newData.playerState && newData.playerState !== oldPlayerState) { + this.triggerEvent('playerStateChange', { + playerState: newData.playerState, + }); + } + if (newData.streamState && newData.streamState !== oldStreamState) { + this.triggerEvent('streamStateChange', { + streamState: newData.streamState, + }); + } + }, + makeResultParams({ startAction, flvParams }) { + this.stat.makeResultParams({ startAction, flvParams: flvParams || this.data.flvParams }); + this.setData({ + playResultStr: '', + idrResultStr: '', + }); + }, + createPlayer() { + this.console.log(`[${this.data.innerId}]`, 'createPlayer', Date.now()); + if (this.data.playerState !== PlayerStateEnum.PlayerIdle) { + this.console.error(`[${this.data.innerId}]`, 'can not createPlayer in playerState', this.data.playerState); + return; + } + + this.changeState({ + playerState: PlayerStateEnum.PlayerPreparing, + }); + }, + onPlayerReady({ detail }) { + this.console.log(`[${this.data.innerId}]`, `==== onPlayerReady in playerState ${this.data.playerState}`); + const oldPlayerState = this.data.playerState; + if (oldPlayerState === PlayerStateEnum.PlayerReady) { + this.console.warn(`[${this.data.innerId}] onPlayerReady again, playerCtx ${detail.livePlayerContext === this.userData.playerCtx ? 'same' : 'different'}`); + } + this.userData.playerComp = detail.playerExport; + this.userData.playerCtx = detail.livePlayerContext; + this.changeState({ + playerState: PlayerStateEnum.PlayerReady, + }); + this.tryTriggerPlay(`${oldPlayerState} -> ${this.data.playerState}`); + }, + onPlayerStartPull() { + this.console.log( + `[${this.data.innerId}] ==== onPlayerStartPull,`, + `flvParams ${this.data.flvParams}, playerPaused ${this.data.playerPaused}, needPauseStream ${this.data.needPauseStream}`, + ); + + if (this.data.playerPaused && this.data.needPauseStream) { + // ios暂停时不会断开连接,一段时间没收到数据就会触发startPull,但needPauseStream时不应该拉流 + // 注意要把playerPaused改成特殊的 'stopped',否则resume会有问题,并且不能用 tryStopPlayer + this.console.warn(`[${this.data.innerId}]`, 'onPlayerStartPull but player paused and need pause stream, stop player'); + try { + this.userData.playerCtx.stop(params); + } catch (err) {} + this.setData({ + playerPaused: 'stopped', + }); + return; + } + + const checkIsFlvValid = this.properties.checkFunctions && this.properties.checkFunctions.checkIsFlvValid; + if (checkIsFlvValid && !checkIsFlvValid({ filename: this.data.flvFilename, params: this.data.flvParams })) { + this.console.warn(`[${this.data.innerId}]`, 'onPlayerStartPull but flv invalid, return'); + 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, + }); + + // 开始拉流 + this.startStream(); + }, + onPlayerClose({ detail }) { + this.console.log(`[${this.data.innerId}]`, `==== onPlayerClose, 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(newStreamState); + }, + onPlayerError({ detail }) { + 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; + xp2pManager.needResetLocalServer = true; + xp2pManager.networkChanged = true; + this.stopAll(P2PStateEnum.P2PLocalNATChanged); + this.changeState({ + hasPlayer: false, + playerState, + }); + } else { + this.changeState({ + playerState, + }); + } + this.handlePlayError(playerState, { msg: `p2pPlayerError: ${code}` }); + }, + onLivePlayerError({ detail }) { + // 参考: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来兼容 + newData.playerDenied = true; + } + this.changeState(newData); + this.handlePlayError(PlayerStateEnum.LivePlayerError, { msg: `livePlayerError: ${detail.errMsg}` }); + }, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + onLivePlayerStateChange({ detail }) { + /* + code说明参考:https://developers.weixin.qq.com/miniprogram/dev/component/live-player.html + 正常启播应该是 + ios: 2008 - 触发startPull - 2001 - 2004 - 2026 (- 2007 - 2004) * n - 2009 - 2003 + android: 触发startPull - 2001 - 2004 - 2026 - 2008 - 2009 - 2003 - 2032 + 注意 + 2001 已经连接服务器,不是连接到本地服务器,而是收到了数据,在 result 之后 + 2008 解码器启动,在 ios/android 的出现顺序不同 + */ + switch (detail.code) { + case 2005: // 视频播放进度 + // 不处理 + break; + case 2006: // 视频播放结束 + case 6000: // 拉流被挂起 + this.console.log(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail.message, `streamState: ${this.data.streamState}`); + break; + case 2003: // 网络接收到首个视频数据包(IDR) + this.console.log(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail.message, `totalBytes: ${this.userData.totalBytes}`); + this.stat.receiveIDR(); + break; + case 2103: // live-player断连, 已启动自动重连 + this.console.warn(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail.message, `streamState: ${this.data.streamState}`); + if (/errCode:-1004(\D|$)/.test(detail.message) || /Failed to connect to/.test(detail.message)) { + // 无法连接本地服务器 + xp2pManager.needResetLocalServer = true; + + // 这时其实网络状态应该也变了,但是网络状态变化事件延迟较大,networkChanged不一定为true + // 所以把 networkChanged 也设为true + xp2pManager.networkChanged = true; + + this.stopAll(P2PStateEnum.P2PLocalNATChanged); + this.changeState({ + hasPlayer: false, + playerState: PlayerStateEnum.LocalServerError, + }); + this.handlePlayError(PlayerStateEnum.LocalServerError, { msg: `livePlayerStateChange: ${detail.code} ${detail.message}` }); + } else { + // 这里一般是一段时间没收到数据,或者数据不是有效的视频流导致的 + /* + 这里可以区分1v1/1v多做不同处理: + - 1v1:网络变化后就不能再次连接上ipc,所以需要调用 checkCanRetry 检查,不能重试的就算播放失败 + - 1v多:网络变化但还是有连接时(比如 wifi->4g),重试可以成功,只是后续会一直从server拉流,无法切换到从其他节点拉流 + - 为了省流量,可以和1v1一样,调用 checkCanRetry 检查 + - 为了体验稳定,可以不特别处理,live-player 会继续重试 + 这里为了简单统一处理 + */ + if (this.checkCanRetry()) { + if (this.data.streamState !== StreamStateEnum.StreamIdle) { + // 哪里有问题导致了重复发起请求,这应该是旧请求的消息,不处理了 + this.console.log(`[${this.data.innerId}]`, `livePlayer auto reconnect but streamState ${this.data.streamState}, ignore`); + return; + } + + this.stat.addStep(PlayStepEnum.AutoReconnect); + + // 前面收到playerStop的时候把streamState变成Idle了,这里再改成WaitPull + this.console.log(`[${this.data.innerId}]`, `livePlayer auto reconnect, ${this.data.streamState} -> ${StreamStateEnum.StreamWaitPull}`); + this.changeState({ + streamState: StreamStateEnum.StreamWaitPull, + }); + } + } + break; + case -2301: // live-player断连,且经多次重连抢救无效,需要提示出错,由用户手动重试 + // 到这里应该已经触发过 onPlayerClose 了 + this.console.error(`[${this.data.innerId}]`, '==== onLivePlayerStateChange', detail.code, detail.message); + this.stat.addStep(PlayStepEnum.FinalStop, { isResult: true }); + this.changeState({ + playerState: xp2pManager.needResetLocalServer + ? PlayerStateEnum.LocalServerError + : PlayerStateEnum.LivePlayerStateError, + streamState: xp2pManager.needResetLocalServer + ? StreamStateEnum.StreamLocalServerError + : StreamStateEnum.StreamIdle, + }); + this.handlePlayError(PlayerStateEnum.LivePlayerStateError, { msg: `livePlayerStateChange: ${detail.code} ${detail.message}` }); + break; + default: + // 这些不特别处理,打个log + if ((detail.code>= 2104 && detail.code < 2200) || detail.code < 0) { + this.console.warn(`[${this.data.innerId}]`, 'onLivePlayerStateChange', detail.code, detail.message); + } else { + this.console.log(`[${this.data.innerId}]`, 'onLivePlayerStateChange', detail.code, detail.message); + } + break; + } + }, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + onLivePlayerNetStatus({ detail }) { + // this.console.log(`[${this.data.innerId}]`, 'onLivePlayerNetStatus', detail.info); + if (!detail.info) { + return; + } + // 不是所有字段都有值,不能直接覆盖整个info,只更新有值的字段 + if (!this.userData.livePlayerInfo) { + this.userData.livePlayerInfo = {}; + } + const { livePlayerInfo } = this.userData; + for (const key in detail.info) { + if (detail.info[key] !== undefined) { + livePlayerInfo[key] = detail.info[key]; + } + } + this.setData({ + livePlayerInfoStr: [ + `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.userData.playerCtx.play(); + }, + }); + } + }, + resetStreamData(newStreamState) { + this.dataCallback = null; + this.clearStreamData(); + this.changeState({ + streamState: newStreamState, + playing: false, + }); + }, + clearStreamData() { + if (this.userData) { + this.userData.chunkTime = 0; + this.userData.chunkCount = 0; + this.userData.totalBytes = 0; + this.userData.livePlayerInfo = null; + } + this.setData({ + firstChunkDataInPaused: null, + livePlayerInfoStr: '', + }); + }, + startStream() { + this.console.log(`[${this.data.innerId}]`, 'startStream'); + if (this.data.playing) { + this.console.log(`[${this.data.innerId}]`, 'already playing'); + return; + } + + // 不检查,直接拉流 + this.doStartStream(); + }, + doStartStream() { + 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.userData; + let chunkTime = 0; + let chunkCount = 0; + let totalBytes = 0; + const dataCallback = (data) => { + if (!data || !data.byteLength) { + return; + } + + if (this.data.isSlow) { + // 模拟丢包 + return; + } + + if (this.data.needPauseStream) { + // 要暂停流,不发数据给player,但是header要记下来后面发。。。 + if (!chunkCount && !this.data.firstChunkDataInPaused) { + this.console.log(`[${this.data.innerId}]`, '==== firstChunkDataInPaused', data.byteLength); + this.setData({ + firstChunkDataInPaused: data, + }); + } + return; + } + + chunkTime = Date.now(); + chunkCount++; + totalBytes += data.byteLength; + if (this.userData) { + this.userData.chunkTime = chunkTime; + this.userData.chunkCount = chunkCount; + this.userData.totalBytes = totalBytes; + } + if (chunkCount === 1) { + this.console.log(`[${this.data.innerId}]`, '==== firstChunk', data.byteLength); + this.changeState({ + streamState: StreamStateEnum.StreamDataReceived, + }); + } + + if (this.userData?.fileObj) { + // 写录像文件 + const writeLen = recordManager.writeRecordFile(this.userData.fileObj, data); + if (writeLen < 0) { + // 写入失败,可能是超过限制了 + this.stopRecording(); + } + } + + playerComp.addChunk(data); + }; + + this.clearStreamData(); + this.changeState({ + streamState: StreamStateEnum.StreamPreparing, + playing: true, + }); + + xp2pManager + .startStream( + this.properties.targetId, + { + flv: { + filename: this.data.flvFilename, + params: this.data.flvParams, + }, + msgCallback, + dataCallback, + }, + { url: this.properties.flvUrl, ...this.properties.streamExInfo }, // 如果还没开始连接,需要这个来开始 + ) + .then((res) => { + if (!this.data.playing) { + // 已经stop了 + return; + } + this.console.log(`[${this.data.innerId}]`, '==== startStream res', res); + if (res === 0) { + this.dataCallback = dataCallback; + this.changeState({ + streamState: StreamStateEnum.StreamStarted, + }); + } else { + this.resetStreamData(StreamStateEnum.StreamStartError); + this.tryStopPlayer(); + this.handlePlayError(StreamStateEnum.StreamStartError, { msg: `startStream res ${res}` }); + } + }) + .catch((res) => { + if (!this.data.playing) { + // 已经stop了 + return; + } + this.console.error(`[${this.data.innerId}]`, '==== startStream error', res); + this.resetStreamData(StreamStateEnum.StreamStartError); + this.tryStopPlayer(); + let type = StreamStateEnum.StreamStartError; + let detail = { msg: `startStream err ${res.errcode}` }; + if (!xp2pManager.state) { + // 说明初始化失败 + type = P2PStateEnum.P2PLocalError; + detail = { + msg: '请检查本地网络是否正常', + isFatalError: true, + }; + } + this.handlePlayError(type, detail); + }); + }, + stopStream(newStreamState = StreamStateEnum.StreamIdle) { + this.console.log(`[${this.data.innerId}]`, `stopStream, ${this.data.streamState} -> ${newStreamState}`); + + // 记下来,因为resetStreamData会把这个改成false + const needStopStream = this.data.playing; + this.resetStreamData(newStreamState); + + if (needStopStream) { + // 如果在录像,取消 + this.cancelRecording(); + + // 拉流中的才需要 xp2pManager.stopStream + this.console.log(`[${this.data.innerId}]`, 'do stopStream', this.properties.targetId, this.data.streamType); + xp2pManager.stopStream(this.properties.targetId, this.data.streamType); + } + }, + changeFlv({ params = '' }) { + this.console.log(`[${this.data.innerId}]`, 'changeFlv', params); + const streamType = getParamValue(params, 'action') || 'live'; + this.setData( + { + flvFile: `${this.data.flvFilename}${params ? `?${params}` : ''}`, + flvParams: params, + streamType, + }, + () => { + // 停掉现在的的 + 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 })) { + this.console.warn(`[${this.data.innerId}]`, 'flv invalid, return'); + // 无效,停止播放 + return; + } + + // 有效,触发播放 + 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'); + }, + ); + }, + stopAll(newP2PState = P2PStateEnum.P2PUnkown) { + this.console.log(`[${this.data.innerId}]`, 'stopAll', newP2PState); + + // 不用等stopPlay的回调,先把流停掉 + let newStreamState = StreamStateEnum.StreamIdle; + if (xp2pManager.needResetLocalServer) { + newStreamState = StreamStateEnum.LocalServerError; + } + this.stopStream(newStreamState); + + this.tryStopPlayer(); + }, + reset() { + if (!this.data.canUseP2P) { + // 不可用的不需要reset + return; + } + + this.console.log(`[${this.data.innerId}]`, 'reset in state', this.data.playerState, this.data.streamState); + + // 一般是 isFatalError 之后重来,msg 保留 + const oldMsg = this.data.playerMsg; + + // 所有的状态都重置 + this.changeState({ + hasPlayer: false, + playerDenied: false, + playerState: PlayerStateEnum.PlayerIdle, + streamState: StreamStateEnum.StreamIdle, + playerMsg: oldMsg, + }); + }, + pause({ success, fail, complete, needPauseStream = false }) { + this.console.log(`[${this.data.innerId}] pause, hasPlayerCrx: ${!!this.userData.playerCtx}, needPauseStream ${needPauseStream}`); + if (!this.userData.playerCtx) { + fail && fail({ errMsg: 'player not ready' }); + complete && complete(); + return; + } + + if (!needPauseStream) { + // 真的pause + this.console.log(`[${this.data.innerId}] playerCtx.pause`); + this.userData.playerCtx.pause({ + success: () => { + this.console.log(`[${this.data.innerId}] playerCtx.pause success`); + this.setData({ + playerPaused: true, + needPauseStream: false, + }); + success && success(); + }, + fail, + complete, + }); + } else { + // android暂停后会断开请求,ios不会断开,但是在收不到数据几秒后会断开请求重试 + // 这里统一处理,needPauseStream时停掉player,保持后续逻辑一致 + // 注意要把playerPaused改成特殊的 'stopped',否则resume会有问题,并且不能用 tryStopPlayer + this.setData({ + playerPaused: 'stopped', + needPauseStream: true, + }); + this.console.log(`[${this.data.innerId}] playerCtx.stop`); + this.userData.playerCtx.stop({ + complete: () => { + this.console.log(`[${this.data.innerId}] playerCtx.stop success`); + this.setData({ + playerPaused: 'stopped', + needPauseStream: true, + }); + success && success(); + complete && complete(); + }, + }); + } + }, + resume({ success, fail, complete }) { + this.console.log(`[${this.data.innerId}] resume, hasPlayerCrx: ${!!this.userData.playerCtx}`); + if (!this.userData.playerCtx) { + fail && fail({ errMsg: 'player not ready' }); + complete && complete(); + return; + } + const funcName = this.data.playerPaused === 'stopped' ? 'play' : 'resume'; + this.console.log(`[${this.data.innerId}] playerCtx.${funcName}`); + this.userData.playerCtx[funcName]({ + success: () => { + this.console.log(`[${this.data.innerId}] playerCtx.${funcName} success, needPauseStream ${this.data.needPauseStream}`); + this.setData({ + playerPaused: false, + // needPauseStream: false, // 还不能接收数据,seek之后才行,外层主动调用resumeStream修改 + }); + success && success(); + }, + fail, + complete, + }); + }, + resumeStream() { + const { needPauseStream, firstChunkDataInPaused } = this.data; + this.console.log(`[${this.data.innerId}] resumeStream, has first chunk data ${!!firstChunkDataInPaused}`); + this.setData({ + needPauseStream: false, + firstChunkDataInPaused: null, + }); + if (this.data.streamState === StreamStateEnum.StreamHeaderParsed + && needPauseStream + && firstChunkDataInPaused + && this.dataCallback + ) { + this.dataCallback(firstChunkDataInPaused); + } + }, + tryStopPlayer(params) { + this.setData({ + playerPaused: false, + needPauseStream: false, + }); + if (this.userData.playerCtx) { + try { + this.userData.playerCtx.stop(params); + } catch (err) {} + } + }, + tryTriggerPlay(reason) { + this.console.log( + `[${this.data.innerId}]`, '==== tryTriggerPlay', + '\n reason', reason, + '\n playerState', this.data.playerState, + '\n streamState', this.data.streamState, + ); + + let isPlayerStateCanPlay = this.data.playerState === PlayerStateEnum.PlayerReady; + if (!isPlayerStateCanPlay && reason === 'replay') { + // 是重试,出错状态也可以触发play + isPlayerStateCanPlay = this.data.playerState === PlayerStateEnum.LivePlayerError + || this.data.playerState === PlayerStateEnum.LivePlayerStateError; + } + if (!this.userData.playerCtx || !isPlayerStateCanPlay) { + 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 })) { + this.console.warn(`[${this.data.innerId}]`, 'flv invalid, return'); + return; + } + + // FIXME 临时调试语音,不拉流 + // this.console.warn(`[${this.data.innerId}]`, '==== disable play for voice debug, return'); + // return; + + // 都准备好了,触发播放,这个会触发 onPlayerStartPull + this.changeState({ + streamState: StreamStateEnum.StreamWaitPull, + }); + this.setData({ + playerPaused: false, + needPauseStream: false, + }); + if (this.data.needPlayer && !this.data.autoPlay) { + // 用 autoPlay 是因为有时候成功调用了play,但是live-player实际并没有开始播放 + this.console.log(`[${this.data.innerId}]`, '==== trigger play by autoPlay'); + this.setData({ autoPlay: true }); + } else { + this.console.log(`[${this.data.innerId}]`, '==== trigger play by playerCtx'); + this.userData.playerCtx.play({ + success: (res) => { + this.console.log(`[${this.data.innerId}]`, 'call play success', res); + }, + fail: (res) => { + this.console.log(`[${this.data.innerId}]`, 'call play fail', res); + }, + }); + } + }, + checkCanRetry() { + let errType; + let isFatalError = false; + let msg = ''; + if (this.data.playerState === PlayerStateEnum.PlayerError + || this.data.playerState === PlayerStateEnum.LivePlayerError + ) { + // 初始化player失败 + errType = this.data.playerState; + if (wx.getSystemInfoSync().platform === 'devtools') { + // 开发者工具里不支持 live-player 和 TCPServer,明确提示 + msg = '不支持在开发者工具中创建p2p-player'; + } else if (this.data.playerDenied) { + // 如果liveplayer是RTC模式,当微信没有系统录音权限时会出错 + msg = '请开启微信的系统录音权限'; + } + isFatalError = true; + } else if (xp2pManager.needResetLocalServer) { + // 本地server出错 + errType = PlayerStateEnum.LocalServerError; + msg = '系统网络服务可能被中断,请重置本地HttpServer'; + isFatalError = true; + } else if (xp2pManager.networkChanged) { + // 网络状态变化 + errType = P2PStateEnum.P2PLocalNATChanged; + msg = '本地网络服务可能发生变化,请重置xp2p模块'; + isFatalError = true; + } + if (isFatalError) { + // 不可恢复错误,销毁player + if (this.data.hasPlayer) { + this.changeState({ hasPlayer: false }); + } + this.console.error(`[${this.data.innerId}] ${errType} isFatalError, trigger playError`); + this.triggerEvent('playError', { + errType, + errMsg: totalMsgMap[errType], + errDetail: { msg }, + isFatalError: true, + }); + return false; + } + return true; + }, + // 自动replay + checkNetworkAndReplay(newStreamState) { + if (!this.checkCanRetry()) { + return; + } + + // 自动重新开始 + this.console.log(`[${this.data.innerId}]`, 'auto replay'); + this.stopStream(newStreamState); + + this.tryStopPlayer({ + success: () => { + this.changeState({ + streamState: StreamStateEnum.StreamWaitPull, + }); + this.console.log(`[${this.data.innerId}]`, 'trigger replay'); + this.userData.playerCtx.play(); + }, + }); + }, + // 手动retry + onClickRetry() { + 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 + this.console.log(`[${this.data.innerId}]`, `can not retry in ${this.data.playing ? 'playing' : this.data.streamState}`); + return; + } + + if (!this.checkCanRetry()) { + return; + } + + this.console.log(`[${this.data.innerId}]`, 'click retry'); + this.makeResultParams({ startAction: 'clickRetry' }); + if (needCreatePlayer) { + this.changeState({ + hasPlayer: true, + }); + } + + this.tryTriggerPlay('clickRetry'); + }, + // 处理播放错误,detail: { msg: string } + handlePlayError(type, detail) { + this.console.log(`[${this.data.innerId}] handlePlayError`, type); + if (this.userData.isDetached) { + this.console.info(`[${this.data.innerId}] handlePlayError after detached, ignore`); + return; + } + + if (!this.checkCanRetry()) { + return; + } + + const isFatalError = detail?.isFatalError; + if (isFatalError) { + // 不可恢复错误,销毁player + if (this.data.hasPlayer) { + this.console.error(`[${this.data.innerId}] ${errType} isFatalError, destroy player`); + this.changeState({ hasPlayer: false }); + } + } + + // 能retry的才提示这个,不能retry的前面已经触发弹窗了 + this.triggerEvent('playError', { + errType: type, + errMsg: totalMsgMap[type] || '播放失败', + errDetail: detail, + isFatalError, + }); + }, + // 处理播放结束 + handlePlayEnd(newStreamState) { + this.console.log(`[${this.data.innerId}] handlePlayEnd`, newStreamState); + if (this.userData.isDetached) { + this.console.info(`[${this.data.innerId}] handlePlayEnd after detached, ignore`); + return; + } + + if (this.properties.autoReplay) { + this.checkNetworkAndReplay(newStreamState); + } else { + this.stopStream(newStreamState); + 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) { + this.console.warn( + `[${this.data.innerId}]`, + `onP2PMessage, targetId error, now ${this.properties.targetId}, receive`, + targetId, + event, + subtype, + ); + return; + } + + switch (event) { + case XP2PEventEnum.Notify: + this.onP2PMessage_Notify(subtype, detail); + break; + + case XP2PEventEnum.DevNotify: + this.triggerEvent('p2pDevNotify', { type: subtype, detail }); + break; + + case XP2PEventEnum.Log: + this.triggerEvent('p2pLog', { type: subtype, detail }); + break; + + default: + this.console.warn(`[${this.data.innerId}]`, 'onP2PMessage, unknown event', event, subtype); + } + }, + onP2PMessage_Notify(type, detail) { + this.console.info(`[${this.data.innerId}] onP2PMessage_Notify ${type}, playing ${this.data.playing}`, 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: + // stream连接成功,注意不要修改state,Connected只在心跳保活时可能收到,不在关键路径上,只是记录一下 + this.stat.addStateTimestamp('streamConnected', { onlyOnce: true }); + break; + case XP2PNotify_SubType.Request: + this.changeState({ + streamState: StreamStateEnum.StreamRequest, + }); + break; + case XP2PNotify_SubType.Parsed: + 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: + { + // 数据传输正常结束 + this.console.log( + `[${this.data.innerId}]`, + `==== Notify ${type}, 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: + // 数据传输出错,当作结束,直播场景可以自动重试 + this.console.error(`[${this.data.innerId}] ==== Notify ${type}`, detail); + this.handlePlayEnd(StreamStateEnum.StreamError); + break; + case XP2PNotify_SubType.Close: + { + if (!this.data.playing) { + // 用户主动关闭,或者因为隐藏等原因挂起了,都会收到 onPlayerClose + return; + } + // 播放中收到了Close,当作播放失败 + this.console.error(`[${this.data.innerId}] ==== Notify ${type}`, detail); + const detailMsg = typeof detail === 'string' ? detail : (detail && detail.type); + this.handlePlayError(StreamStateEnum.StreamError, { msg: `p2pNotify: ${type}, ${detailMsg}`, detail }); + } + break; + case XP2PNotify_SubType.Disconnect: + { + // p2p流断开 + this.console.error(`[${this.data.innerId}] ==== Notify ${type}`, detail); + this.stopAll(P2PStateEnum.ServiceError); + const detailMsg = typeof detail === 'string' ? detail : (detail && detail.type); + this.handlePlayError(P2PStateEnum.ServiceError, { msg: `p2pNotify: ${type}, ${detailMsg}`, detail }); + } + break; + } + }, + // 以下是播放器控件相关的 + changeMuted() { + this.setData({ + 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.userData.playerCtx) { + return Promise.reject({ errMsg: 'player not ready' }); + } + return new Promise((resolve, reject) => { + this.userData.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 }); + }, + toggleSlow() { + this.setData({ isSlow: !this.data.isSlow }); + }, + toggleRecording() { + if (this.data.isRecording) { + this.stopRecording(); + } else { + this.startRecording(); + } + }, + async startRecording(recordFilename) { + if (this.data.isRecording || this.userData.fileObj) { + // 已经在录像 + return; + } + + const modalRes = await wx.showModal({ + title: '确定开始录像吗?', + content: `录像需要重新拉流,并且可能影响播放性能,请谨慎操作。\n仅保留最新的1个录像,最大支持 ${MAX_FILE_SIZE_IN_M}MB。`, + }); + if (!modalRes || !modalRes.confirm) { + return; + } + this.console.log(`[${this.data.innerId}] confirm startRecording`); + + // 保存录像文件要有flv头,停掉重新拉流 + if (this.data.playing) { + this.stopStream(); + } + this.tryStopPlayer(); + + // 准备录像文件,注意要在 stopStream 之后 + let realRecordFilename = recordFilename; + if (!realRecordFilename) { + if (this.data.p2pMode === 'ipc') { + const streamType = getParamValue(this.data.flvParams, 'action') || 'live'; + realRecordFilename = `${this.data.p2pMode}-${this.properties.productId}-${this.properties.deviceName}-${streamType}`; + } else { + realRecordFilename = `${this.data.p2pMode}-${this.data.flvFilename}`; + } + } + const fileObj = recordManager.openRecordFile(realRecordFilename); + this.userData.fileObj = fileObj; + this.setData({ + isRecording: !!fileObj, + }); + this.console.log(`[${this.data.innerId}] record fileName ${fileObj && fileObj.fileName}`); + + // 重新play + this.changeState({ + streamState: StreamStateEnum.StreamWaitPull, + }); + this.console.log(`[${this.data.innerId}]`, 'trigger record play'); + this.userData.playerCtx.play(); + }, + async stopRecording() { + if (!this.data.isRecording || !this.userData.fileObj) { + // 没在录像 + return; + } + + this.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); + this.console.log(`[${this.data.innerId}]`, 'saveRecordFile res', fileRes); + + if (!fileRes) { + wx.showToast({ + title: '录像失败', + icon: 'error', + }); + return; + } + + // 保存到相册 + try { + await recordManager.saveVideoToAlbum(fileRes.fileName); + wx.showModal({ + title: '录像已保存到相册', + showCancel: false, + }); + } catch (err) { + wx.showModal({ + title: '保存录像到相册失败', + content: err.errMsg, + showCancel: false, + }); + } + }, + cancelRecording() { + if (!this.data.isRecording || !this.userData.fileObj) { + // 没在录像 + return; + } + + this.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-common-player-quick/player.json b/demo/miniprogram/components/iot-p2p-common-player-quick/player.json new file mode 100644 index 0000000..32640e0 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-common-player-quick/player.json @@ -0,0 +1,3 @@ +{ + "component": true +} \ No newline at end of file diff --git a/demo/miniprogram/components/iot-p2p-common-player-quick/player.wxml b/demo/miniprogram/components/iot-p2p-common-player-quick/player.wxml new file mode 100644 index 0000000..23f5009 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-common-player-quick/player.wxml @@ -0,0 +1,80 @@ + + + + + {{playerMsg}} + + + {{playerPaused ? 'paused' : ''}} + {{muted ? 'muted' : 'notMuted'}} + {{orientation}} + snapshot + + + + 调试信息 + + + + + plugin: xp2p {{xp2pVersion}} / p2p-player {{p2pPlayerVersion}} + xp2pUUID: {{xp2pUUID}} + + flvFile: + + {{flvFile}} + + + playerMode: {{mode}} + playerState: {{playerState}} pauseType: {{playerPaused}} + p2pState: {{p2pState}} + streamState: {{streamState}} + + playing: {{playing}} + {{isSlow ? '恢复' : '模拟丢包'}} + + + record: + {{isRecording ? '停止录像' : '开始录像'}} + 开始播放后才可以录像 + + + livePlayerInfo: + + {{livePlayerInfoStr}} + + + + playResult: + + {{playResultStr}} + + {{idrResultStr}} + + + + diff --git a/demo/miniprogram/components/iot-p2p-common-player-quick/player.wxss b/demo/miniprogram/components/iot-p2p-common-player-quick/player.wxss new file mode 100644 index 0000000..1244589 --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-common-player-quick/player.wxss @@ -0,0 +1,65 @@ +@import '../../common.wxss'; + +.iot-player { + box-sizing: border-box; + position: relative; +} + +.player-container { + box-sizing: border-box; + position: relative; + width: 100%; + height: 420rpx; + background-color: black; +} +.player-container live-player { + width: 100% !important; + height: 100% !important; +} + +.player-container .player-message { + position: absolute; + left: 0; + top: 50%; + width: 100%; + text-align: center; + color: white; +} + +.player-container .player-controls-container.left { + position: absolute; + left: 20rpx; + bottom: 20rpx; +} +.player-container .player-controls-container.right { + position: absolute; + right: 20rpx; + bottom: 20rpx; +} +.player-container .player-controls-container .player-controls { + display: flex; +} +.player-container .player-controls-container .player-controls .player-control-item { + position: inline-block; + vertical-align: bottom; + color: white; +} +.player-container .player-controls-container.left .player-controls .player-control-item { + margin-right: 20rpx; +} +.player-container .player-controls-container.right .player-controls .player-control-item { + margin-left: 20rpx; +} + +.player-container .debug-info-switch-container { + position: absolute; + left: 20rpx; + bottom: 20rpx; +} +.player-container .debug-info-switch-container .debug-info-switch { + position: inline-block; + vertical-align: bottom; + color: white; + /* opacity: 0; */ + margin-right: 20rpx; +} diff --git a/demo/miniprogram/components/iot-p2p-common-player-quick/stat.js b/demo/miniprogram/components/iot-p2p-common-player-quick/stat.js new file mode 100644 index 0000000..535ed8a --- /dev/null +++ b/demo/miniprogram/components/iot-p2p-common-player-quick/stat.js @@ -0,0 +1,179 @@ +import { PlayerStateEnum, StreamStateEnum } from './common'; + +// 启播步骤 +export const PlayStepEnum = { + CreatePlayer: 'StepCreatePlayer', + 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, + }, + + // stream + [StreamStateEnum.StreamReceivePull]: { + step: PlayStepEnum.ConnectLocalServer, + fromState: StreamStateEnum.StreamWaitPull, + toState: StreamStateEnum.StreamReceivePull, + }, + [StreamStateEnum.StreamLocalServerError]: { + step: PlayStepEnum.ConnectLocalServer, + fromState: StreamStateEnum.StreamWaitPull, + toState: StreamStateEnum.StreamLocalServerError, + }, + [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; + onPlayStepsChange; + onPlayResultChange; + onIdrResultChange; + + playResultParams; + idrResultParams; + + constructor({ innerId, onPlayStepsChange, onPlayResultChange, onIdrResultChange }) { + this.innerId = innerId; + this.onPlayStepsChange = onPlayStepsChange; + this.onPlayResultChange = onPlayResultChange; + this.onIdrResultChange = onIdrResultChange; + } + + makeResultParams({ startAction, flvParams }) { + 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]) { + 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]) { + console.warn(`[${this.innerId}][stat]`, 'addStep', step, 'but no toState', toState); + return; + } + toTime = playTimestamps[toState]; + } else { + toTime = now; + } + + const timeCost = toTime - fromTime; + 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; + 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/lib/xp2pManager.js b/demo/miniprogram/lib/xp2pManager.js index 11b1dc1..3e00158 100644 --- a/demo/miniprogram/lib/xp2pManager.js +++ b/demo/miniprogram/lib/xp2pManager.js @@ -34,6 +34,69 @@ const parseCommandResData = (data) => { return JSON.parse(jsonStr); }; +class Xp2pService { + constructor({ targetId, streamInfo }) { + this.targetId = targetId; + this.streamInfo = streamInfo; + this.p2pState = ''; + this.promise = null; + console.log(`[Xp2pService_${this.targetId}] created`, streamInfo); + } + + get started() { + return this.p2pState === 'started'; + } + + async start() { + console.log(`[Xp2pService_${this.targetId}] start`); + if (this.p2pState === 'started') { + // 已经初始化好了 + console.log(`[Xp2pService_${this.targetId}] already started`); + return Promise.resolve(0); + } + + if (this.promise) { + // 正在启动 + return await this.promise; + } + + this.p2pState = 'preparing'; + this.promise = p2pExports.startP2PService( + this.targetId, + this.streamInfo, + { + msgCallback: this.msgCallback.bind(this), + }, + ); + const res = await this.promise; + if (res === 0) { + this.p2pState = 'started'; + console.log(`[Xp2pService_${this.targetId}] started`); + } else { + this.p2pState = 'startError'; + console.error(`[Xp2pService_${this.targetId}] startError`); + } + return res; + } + + stop() { + console.log(`[Xp2pService_${this.targetId}] stop`); + this.promise = null; + this.p2pState = ''; + return p2pExports.stopP2PService(this.targetId); + } + + msgCallback(event, subtype, detail) { + console.log(`[Xp2pService_${this.targetId}] msgCallback`, event, subtype, detail); + switch (subtype) { + case 'serviceDisconnect': + this.p2pState = 'disconnect'; + console.log(`[Xp2pService_${this.targetId}] disconnect`); + break; + } + } +} + class Xp2pManager { get P2PPlayerVersion() { return playerPlugin?.Version; @@ -125,6 +188,9 @@ class Xp2pManager { // 自定义信令用 this._msgSeq = 0; + // targetId -> service + this._serviceMap = {}; + if (!p2pExports) { const xp2pPlugin = requirePlugin('xp2p'); p2pExports = xp2pPlugin.p2p; @@ -427,23 +493,73 @@ class Xp2pManager { return this._promise = promise; } - startP2PService(targetId, streamInfo, callbacks) { + // service 的 msgCallback 统一在这里处理,不用传了 + async startP2PService(targetId, streamInfo) { console.log('Xp2pManager: startP2PService', targetId, streamInfo); - return p2pExports.startP2PService(targetId, streamInfo, callbacks); + + if (!this.isModuleActive) { + // 还没初始化或者已经异常,重新初始化 + console.log('Xp2pManager: module not active, init first'); + const res = await this.initModule(); + if (res !== 0) { + return res; + } + } + + if (!this._serviceMap[targetId]) { + this._serviceMap[targetId] = new Xp2pService({ + targetId, + streamInfo, + }); + } + + const service = this._serviceMap[targetId]; + const res = await service.start(); + if (res !== 0) { + delete this._serviceMap[targetId]; + } + + return res; } stopP2PService(targetId) { console.log('Xp2pManager: stopP2PService', targetId); - return p2pExports.stopP2PService(targetId); + + if (!targetId || !this._serviceMap[targetId]) { + console.error('Xp2pManager: stopP2PService param error'); + return; + } + + // 先delete + const service = this._serviceMap[targetId]; + delete this._serviceMap[targetId]; + + return service.stop(); + } + + getP2PServiceState(targetId) { + const service = this._serviceMap[targetId]; + return service?.p2pState; } - getServiceInitInfo(targetId) { - return p2pExports.getServiceInitInfo(targetId); + isP2PServiceStarted(targetId) { + const service = this._serviceMap[targetId]; + return service?.started; } - startStream(targetId, { flv, msgCallback, dataCallback }) { - console.log('Xp2pManager: startStream', targetId, flv); - return p2pExports.startStream(targetId, { flv, msgCallback, dataCallback }); + async startStream(targetId, { flv, msgCallback, dataCallback }, streamInfo) { + console.log('Xp2pManager: startStream', targetId, flv, streamInfo); + + if (!this.isP2PServiceStarted(targetId)) { + // 还没连接 + console.log('Xp2pManager: service not started, start first'); + const res = await this.startP2PService(targetId, streamInfo); + if (res !== 0) { + return res; + } + } + + return await p2pExports.startStream(targetId, { flv, msgCallback, dataCallback }); } stopStream(targetId, streamType) { From 33833d4e528d5381b219a3946abe694137915e5f Mon Sep 17 00:00:00 2001 From: sunnywwcao Date: 2022年9月27日 11:39:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(demo):=20=E6=9B=B4=E6=96=B0=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/miniprogram/.gitignore | 1 - demo/miniprogram/app.js | 19 --- demo/miniprogram/app.json | 3 +- demo/miniprogram/app.wxss | 4 + .../iot-p2p-common-player/common.js | 6 +- .../iot-p2p-common-player/player.js | 72 ++++++---- .../iot-p2p-common-player/player.wxml | 4 +- .../components/iot-p2p-common-player/stat.js | 2 - .../components/iot-p2p-input/input.js | 7 + .../components/iot-p2p-input/input.wxml | 3 +- .../components/iot-p2p-mjpg-player/player.js | 5 - .../iot-p2p-mjpg-player/player.wxml | 4 +- .../components/iot-p2p-player-ipc/player.js | 130 +++++++++--------- .../components/iot-p2p-player-ipc/player.wxml | 3 +- .../iot-p2p-player-server/player.js | 28 ++-- .../iot-p2p-player-server/player.wxml | 2 +- .../components/iot-p2p-voice/voice.js | 16 +-- .../components/mock-p2p-player/player.js | 6 - demo/miniprogram/config/devices.js | 74 +++++++++- demo/miniprogram/config/streams.js | 3 +- demo/miniprogram/pages/index/index.js | 25 ++-- demo/miniprogram/pages/index/index.wxml | 99 ++++++------- demo/miniprogram/pages/xp2p-demo-1vN/demo.js | 44 +++++- .../miniprogram/pages/xp2p-demo-1vN/demo.wxml | 6 +- .../pages/xp2p-singleplayer/demo.js | 108 +++++++++++---- .../pages/xp2p-singleplayer/demo.wxml | 9 +- demo/miniprogram/project.config.json | 56 -------- demo/miniprogram/utils.js | 5 + 28 files changed, 413 insertions(+), 331 deletions(-) delete mode 100644 demo/miniprogram/.gitignore delete mode 100644 demo/miniprogram/project.config.json diff --git a/demo/miniprogram/.gitignore b/demo/miniprogram/.gitignore deleted file mode 100644 index 8647c30..0000000 --- a/demo/miniprogram/.gitignore +++ /dev/null @@ -1 +0,0 @@ -project.private.config.json diff --git a/demo/miniprogram/app.js b/demo/miniprogram/app.js index e70db6d..c870791 100644 --- a/demo/miniprogram/app.js +++ b/demo/miniprogram/app.js @@ -18,23 +18,4 @@ App({ }; } }, - - preInitP2P() { - if (this.xp2pManager) { - // 已经加载过了 - this.logger.log('app: preload xp2pManager, already has xp2pManager'); - return Promise.resolve(this.xp2pManager); - } - - return new Promise((resolve, reject) => { - this.logger.log('app: preload xp2pManager'); - require.async('./libs/xp2pManager.js').then(pkg => { - this.logger.log(`app: preload xp2pManager success, now xp2pManager ${!!this.xp2pManager}`); - resolve(this.xp2pManager); - }).catch(({mod, errMsg}) => { - this.logger.error(`app: preload xp2pManager fail, path: ${mod}, ${errMsg}`); - reject({mod, errMsg}); - }); - }); - }, }); diff --git a/demo/miniprogram/app.json b/demo/miniprogram/app.json index cbbc406..f1961fe 100644 --- a/demo/miniprogram/app.json +++ b/demo/miniprogram/app.json @@ -18,7 +18,7 @@ "export": "exportForPlugin.js" }, "xp2p": { - "version": "3.1.6", + "version": "dev", "provider": "wx1319af22356934bf" } }, @@ -28,6 +28,7 @@ "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-player-quick": "components/iot-p2p-common-player-quick/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", diff --git a/demo/miniprogram/app.wxss b/demo/miniprogram/app.wxss index 4644bcd..ec36773 100644 --- a/demo/miniprogram/app.wxss +++ b/demo/miniprogram/app.wxss @@ -1,5 +1,9 @@ .page-body { position: relative; + /* padding: 20rpx 0; */ +} + +.page-body-padding { padding: 20rpx 0; } diff --git a/demo/miniprogram/components/iot-p2p-common-player/common.js b/demo/miniprogram/components/iot-p2p-common-player/common.js index a5ff918..a961f0e 100644 --- a/demo/miniprogram/components/iot-p2p-common-player/common.js +++ b/demo/miniprogram/components/iot-p2p-common-player/common.js @@ -12,10 +12,11 @@ export const PlayerStateEnum = { export const P2PStateEnum = { P2PIdle: 'P2PIdle', P2PUnkown: 'P2PUnkown', + P2PLocalError: 'P2PLocalError', + P2PLocalNATChanged: 'P2PLocalNATChanged', P2PIniting: 'P2PIniting', P2PInited: 'P2PInited', P2PInitError: 'P2PInitError', - P2PLocalNATChanged: 'P2PLocalNATChanged', ServicePreparing: 'ServicePreparing', ServiceStarted: 'ServiceStarted', ServiceStartError: 'ServiceStartError', @@ -51,10 +52,11 @@ export const totalMsgMap = { [PlayerStateEnum.LocalServerError]: '本地HttpServer错误', [P2PStateEnum.P2PUnkown]: 'P2PUnkown', + [P2PStateEnum.P2PLocalError]: 'P2PLocalError', + [P2PStateEnum.P2PLocalNATChanged]: '本地NAT发生变化', [P2PStateEnum.P2PIniting]: '正在初始化p2p模块...', [P2PStateEnum.P2PInited]: '初始化p2p模块完成', [P2PStateEnum.P2PInitError]: '初始化p2p模块失败', - [P2PStateEnum.P2PLocalNATChanged]: '本地NAT发生变化', [P2PStateEnum.ServicePreparing]: '正在启动p2p服务...', [P2PStateEnum.ServiceStarted]: '启动p2p服务完成', [P2PStateEnum.ServiceStartError]: '启动p2p服务失败', diff --git a/demo/miniprogram/components/iot-p2p-common-player/player.js b/demo/miniprogram/components/iot-p2p-common-player/player.js index d70720d..11a3e73 100644 --- a/demo/miniprogram/components/iot-p2p-common-player/player.js +++ b/demo/miniprogram/components/iot-p2p-common-player/player.js @@ -235,6 +235,7 @@ Component({ // 渲染无关,不放在data里,以免影响性能 this.userData = { + isDetached: false, playerComp: null, playerCtx: null, chunkTime: 0, @@ -280,7 +281,7 @@ Component({ p2pState = P2PStateEnum.P2PIdle; playerState = PlayerStateEnum.PlayerIdle; } else { - p2pState = P2PStateEnum.P2PInitError; + p2pState = P2PStateEnum.P2PLocalError; playerState = PlayerStateEnum.PlayerError; playerMsg = isP2PModeValid ? '您的微信基础库版本过低,请升级后再使用' : `无效的p2pType: ${this.properties.p2pMode}`; } @@ -359,6 +360,7 @@ Component({ detached() { // 在组件实例被从页面节点树移除时执行 this.console.log(`[${this.data.innerId}]`, '==== detached'); + this.userData.isDetached = true; this.stopAll(); this.console.log(`[${this.data.innerId}]`, '==== detached end'); }, @@ -476,8 +478,11 @@ Component({ }); this.tryTriggerPlay(`${oldPlayerState} -> ${this.data.playerState}`); }, - onPlayerStartPull({ 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); + onPlayerStartPull() { + this.console.log( + `[${this.data.innerId}] ==== onPlayerStartPull in p2pState ${this.data.p2pState},`, + `flvParams ${this.data.flvParams}, playerPaused ${this.data.playerPaused}, needPauseStream ${this.data.needPauseStream}`, + ); if (this.data.playerPaused && this.data.needPauseStream) { // ios暂停时不会断开连接,一段时间没收到数据就会触发startPull,但needPauseStream时不应该拉流 @@ -796,23 +801,13 @@ Component({ this.console.log(`[${this.data.innerId}]`, 'startP2PService', targetId, flvUrl, streamExInfo); - const msgCallback = (event, subtype, detail) => { - this.onP2PServiceMessage(targetId, event, subtype, detail); - }; - this.changeState({ currentP2PId: targetId, p2pState: P2PStateEnum.ServicePreparing, }); xp2pManager - .startP2PService( - targetId, - { url: flvUrl, ...streamExInfo }, - { - msgCallback, - }, - ) + .startP2PService(targetId, { url: flvUrl, ...streamExInfo }) .then((res) => { this.console.log(`[${this.data.innerId}]`, '==== startP2PService res', res); if (res === 0) { @@ -1043,13 +1038,6 @@ Component({ || checkState === P2PStateEnum.ServiceStartError || checkState === P2PStateEnum.ServiceError; }, - isStreamInErrorState(streamState) { - const checkState = streamState || this.data.streamState; - return checkState === StreamStateEnum.StreamLocalServerError - || checkState === StreamStateEnum.StreamCheckError - || checkState === StreamStateEnum.StreamStartError - || checkState === StreamStateEnum.StreamError; - }, stopAll(newP2PState = P2PStateEnum.P2PUnkown) { if (!this.data.currentP2PId) { // 没prepare,或者已经stop了 @@ -1187,7 +1175,6 @@ Component({ } }, tryTriggerPlay(reason) { - const isReplay = reason === 'replay'; this.console.log( `[${this.data.innerId}]`, '==== tryTriggerPlay', '\n reason', reason, @@ -1203,7 +1190,7 @@ Component({ const isP2PStateCanPlay = this.data.p2pState === P2PStateEnum.ServiceStarted; let isPlayerStateCanPlay = this.data.playerState === PlayerStateEnum.PlayerReady; - if (!isPlayerStateCanPlay && isReplay) { + if (!isPlayerStateCanPlay && reason === 'replay') { // 是重试,出错状态也可以触发play isPlayerStateCanPlay = this.data.playerState === PlayerStateEnum.LivePlayerError || this.data.playerState === PlayerStateEnum.LivePlayerStateError; @@ -1281,14 +1268,15 @@ Component({ isFatalError = true; } if (isFatalError) { - // 不可恢复错误,销毁player + // 不可恢复错误,断开p2p连接 if (!this.isP2PInErrorState(this.data.p2pState)) { this.stopAll(); } + // 不可恢复错误,销毁player if (this.data.hasPlayer) { this.changeState({ hasPlayer: false }); } - this.console.log(`[${this.data.innerId}] ${errType} isFatalError, trigger playError`); + this.console.error(`[${this.data.innerId}] ${errType} isFatalError, trigger playError`); this.triggerEvent('playError', { errType, errMsg: totalMsgMap[errType], @@ -1369,19 +1357,45 @@ Component({ }, // 处理播放错误,detail: { msg: string } handlePlayError(type, detail) { + this.console.log(`[${this.data.innerId}] handlePlayError`, type); + if (this.userData.isDetached) { + this.console.info(`[${this.data.innerId}] handlePlayError after detached, ignore`); + return; + } + if (!this.checkCanRetry()) { return; } + const isFatalError = detail?.isFatalError; + if (isFatalError) { + // 不可恢复错误,断开p2p连接 + if (!this.isP2PInErrorState(this.data.p2pState)) { + this.stopAll(); + } + // 不可恢复错误,销毁player + if (this.data.hasPlayer) { + this.console.error(`[${this.data.innerId}] ${errType} isFatalError, destroy player`); + this.changeState({ hasPlayer: false }); + } + } + // 能retry的才提示这个,不能retry的前面已经触发弹窗了 this.triggerEvent('playError', { errType: type, errMsg: totalMsgMap[type] || '播放失败', errDetail: detail, + isFatalError, }); }, // 处理播放结束 handlePlayEnd(newStreamState) { + this.console.log(`[${this.data.innerId}] handlePlayEnd`, newStreamState); + if (this.userData.isDetached) { + this.console.info(`[${this.data.innerId}] handlePlayEnd after detached, ignore`); + return; + } + if (this.properties.autoReplay) { this.checkNetworkAndReplay(newStreamState); } else { @@ -1443,7 +1457,7 @@ Component({ } }, onP2PMessage_Notify(type, detail) { - this.console.info(`[${this.data.innerId}]`, 'onP2PMessage_Notify', type, detail); + this.console.info(`[${this.data.innerId}] onP2PMessage_Notify ${type}, playing ${this.data.playing}`, 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); @@ -1478,7 +1492,8 @@ Component({ case XP2PNotify_SubType.Eof: { // 数据传输正常结束 - this.console.log(`[${this.data.innerId}]`, + 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, ); @@ -1492,7 +1507,7 @@ Component({ } break; case XP2PNotify_SubType.Fail: - // 数据传输出错 + // 数据传输出错,当作结束,直播场景可以自动重试 this.console.error(`[${this.data.innerId}]`, `==== Notify ${type} in p2pState ${this.data.p2pState}`, detail); this.handlePlayEnd(StreamStateEnum.StreamError); break; @@ -1503,6 +1518,7 @@ Component({ return; } // 播放中收到了Close,当作播放失败 + this.console.error(`[${this.data.innerId}] ==== Notify ${type}`, detail); const detailMsg = typeof detail === 'string' ? detail : (detail && detail.type); this.handlePlayError(StreamStateEnum.StreamError, { msg: `p2pNotify: ${type}, ${detailMsg}`, detail }); } diff --git a/demo/miniprogram/components/iot-p2p-common-player/player.wxml b/demo/miniprogram/components/iot-p2p-common-player/player.wxml index 2c37255..23f5009 100644 --- a/demo/miniprogram/components/iot-p2p-common-player/player.wxml +++ b/demo/miniprogram/components/iot-p2p-common-player/player.wxml @@ -1,7 +1,7 @@ P2P模式: 1v1 - + 取消 + diff --git a/demo/miniprogram/components/iot-p2p-mjpg-player/player.js b/demo/miniprogram/components/iot-p2p-mjpg-player/player.js index cff040a..d060574 100644 --- a/demo/miniprogram/components/iot-p2p-mjpg-player/player.js +++ b/demo/miniprogram/components/iot-p2p-mjpg-player/player.js @@ -94,7 +94,6 @@ Component({ // player状态 hasPlayer: false, - playerId: '', // 这是 p2p-mjpg-player 组件的id,不是自己的id playerState: MjpgPlayerStateEnum.MjpgPlayerIdle, playerMsg: '', @@ -164,14 +163,12 @@ Component({ const onlyp2p = this.properties.onlyp2p || false; const needPlayer = !onlyp2p; const hasPlayer = needPlayer && this.properties.targetId && this.properties.mjpgFile && streamType; - const playerId = `${this.data.innerId}-player`; // 这是 p2p-mjpg-player 组件的id,不是自己的id this.setData({ flvFilename, flvParams, streamType, needPlayer, hasPlayer, - playerId, }); this.createPlayer(); @@ -347,7 +344,6 @@ Component({ this.clearStreamData(); - console.log(`[${this.data.innerId}]`, 'call play'); this.userData.playerCtx.play({ success, fail, complete }); }, stop({ success, fail, complete } = {}) { @@ -360,7 +356,6 @@ Component({ this.clearStreamData(); - console.log(`[${this.data.innerId}]`, 'call stop'); this.userData.playerCtx.stop({ success, fail, complete }); }, startStream() { diff --git a/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxml b/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxml index dec19ed..c779adb 100644 --- a/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxml +++ b/demo/miniprogram/components/iot-p2p-mjpg-player/player.wxml @@ -1,7 +1,7 @@ { console.log(`[${this.data.innerId}]`, 'call pause success'); @@ -590,7 +586,7 @@ Component({ }); }, resumePlayer({ success, fail } = {}) { - if (!this.data.player) { + if (!this.userData.player) { console.log(`[${this.data.innerId}]`, 'no player'); return; } @@ -601,7 +597,7 @@ Component({ } console.log(`[${this.data.innerId}]`, 'call resume'); - this.data.player.resume({ + this.userData.player.resume({ success: () => { console.log(`[${this.data.innerId}]`, 'call resume success'); this.setData({ @@ -618,7 +614,7 @@ Component({ }, pauseLive() { console.log(`[${this.data.innerId}]`, 'pauseLive'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } @@ -627,7 +623,7 @@ Component({ }, resumeLive() { console.log(`[${this.data.innerId}]`, 'resumeLive'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } @@ -709,11 +705,11 @@ Component({ }, startPlayback() { console.log(`[${this.data.innerId}]`, 'startPlayback'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } - if (!this.data.player) { + if (!this.userData.player) { console.log(`[${this.data.innerId}]`, 'no player'); return; } @@ -736,15 +732,15 @@ Component({ const params = `action=${this.data.streamType}&channel=0&${this.data.inputPlaybackTime}`; console.log(`[${this.data.innerId}]`, 'call changeFlv', params); - this.data.player.changeFlv({ params }); + this.userData.player.changeFlv({ params }); }, stopPlayback() { console.log(`[${this.data.innerId}]`, 'stopPlayback'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } - if (!this.data.player) { + if (!this.userData.player) { console.log(`[${this.data.innerId}]`, 'no player'); return; } @@ -753,7 +749,7 @@ Component({ const params = `action=${this.data.streamType}&channel=0`; console.log(`[${this.data.innerId}]`, 'call changeFlv', params); - this.data.player.changeFlv({ params }); + this.userData.player.changeFlv({ params }); }, inputIPCDownloadFilename(e) { this.setData({ @@ -762,7 +758,7 @@ Component({ }, downloadInputFile() { console.log(`[${this.data.innerId}]`, 'downloadInputFile'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } @@ -976,7 +972,7 @@ Component({ }, getPlaybackProgress({ success, fail } = {}) { console.log(`[${this.data.innerId}]`, 'getPlaybackProgress'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); fail && fail(); return; @@ -1025,7 +1021,7 @@ Component({ }, seekPlayback() { console.log(`[${this.data.innerId}]`, 'seekPlayback'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } @@ -1102,7 +1098,7 @@ Component({ }, pausePlayback() { console.log(`[${this.data.innerId}]`, 'pausePlayback'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } @@ -1157,7 +1153,7 @@ Component({ }, resumePlayback() { console.log(`[${this.data.innerId}]`, 'resumePlayback'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } @@ -1195,7 +1191,7 @@ Component({ .then((res) => { // 不管成功失败都resumeStream console.log(`[${this.data.innerId}]`, 'call resumeStream'); - this.data.player.resumeStream(); + this.userData.player.resumeStream(); console.log(`[${this.data.innerId}]`, 'playback_resume res', res); const status = parseInt(res && res.status, 10); // 有的设备返回的 status 是字符串,兼容一下 @@ -1217,7 +1213,7 @@ Component({ .catch((errmsg) => { // 不管成功失败都resumeStream console.log(`[${this.data.innerId}]`, 'call resumeStream'); - this.data.player.resumeStream(); + this.userData.player.resumeStream(); console.log(`[${this.data.innerId}]`, 'playback_resume fail', errmsg); this.showToast('playback_resume fail'); @@ -1256,7 +1252,7 @@ Component({ } // 不管成功失败都resumeStream console.log(`[${this.data.innerId}]`, `call resumeStream after ${Date.now() - start}ms`); - this.data.player.resumeStream(); + this.userData.player.resumeStream(); console.log(`[${this.data.innerId}]`, 'playback_seek res', res); const status = parseInt(res && res.status, 10); // 有的设备返回的 status 是字符串,兼容一下 @@ -1287,7 +1283,7 @@ Component({ } // 不管成功失败都resumeStream console.log(`[${this.data.innerId}]`, `call resumeStream after ${Date.now() - start}ms`); - this.data.player.resumeStream(); + this.userData.player.resumeStream(); console.log(`[${this.data.innerId}]`, 'playback_seek fail', errmsg); this.showToast('playback_seek fail'); @@ -1295,7 +1291,7 @@ Component({ }, sendInnerCommand(e, inputParams = undefined, callback = undefined) { console.log(`[${this.data.innerId}]`, 'sendInnerCommand'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } @@ -1357,7 +1353,7 @@ Component({ }, sendCommand() { console.log(`[${this.data.innerId}]`, 'sendCommand'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } @@ -1397,7 +1393,7 @@ Component({ }, toggleVoice() { console.log(`[${this.data.innerId}]`, 'toggleVoice in voiceState', this.data.voiceState); - if (!this.data.voiceComp) { + if (!this.userData.voiceComp) { console.log(`[${this.data.innerId}]`, 'no voiceComp'); return; } @@ -1417,7 +1413,7 @@ Component({ }, controlDevicePTZ(e) { console.log(`[${this.data.innerId}]`, 'controlDevicePTZ'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } @@ -1457,7 +1453,7 @@ Component({ }, releasePTZBtn() { console.log(`[${this.data.innerId}]`, 'releasePTZBtn'); - if (!this.data.p2pReady) { + if (!this.isP2PReady()) { console.log(`[${this.data.innerId}]`, 'p2p not ready'); return; } diff --git a/demo/miniprogram/components/iot-p2p-player-ipc/player.wxml b/demo/miniprogram/components/iot-p2p-player-ipc/player.wxml index 10ad0a5..da98d0a 100644 --- a/demo/miniprogram/components/iot-p2p-player-ipc/player.wxml +++ b/demo/miniprogram/components/iot-p2p-player-ipc/player.wxml @@ -1,6 +1,6 @@ - diff --git a/demo/miniprogram/components/iot-p2p-player-server/player.js b/demo/miniprogram/components/iot-p2p-player-server/player.js index 550c707..59dd1e1 100644 --- a/demo/miniprogram/components/iot-p2p-player-server/player.js +++ b/demo/miniprogram/components/iot-p2p-player-server/player.js @@ -1,3 +1,4 @@ +import { getClockTime } from '../../utils'; import { getXp2pManager } from '../../lib/xp2pManager'; const xp2pManager = getXp2pManager(); @@ -21,6 +22,7 @@ Component({ // 以下仅供调试,正式组件不需要 onlyp2p: { type: Boolean, + value: false, }, }, data: { @@ -29,7 +31,6 @@ Component({ // 这些是控制player和p2p的 playerId: 'iot-p2p-common-player', player: null, - p2pReady: false, // 节点列表 peerlist: '', @@ -106,30 +107,17 @@ Component({ this.triggerEvent(e.type, e.detail); }, // 以下是 common-player 的事件 - onP2PStateChange(e) { - console.log(`[${this.data.innerId}]`, 'onP2PStateChange', e.detail.p2pState); - const p2pReady = e.detail.p2pState === 'ServiceStarted'; - this.setData({ p2pReady }); - if (!p2pReady) { + onStreamStateChange(e) { + console.log(`[${this.data.innerId}]`, 'onStreamStateChange', e.detail.p2pState); + if (e.detail.streamState === 'StreamWaitPull') { + // 准备拉流,清理数据 this.setData({ peerlist: '', subscribeLog: '', }); } - this.passEvent(e); - }, - formatFixed2(n, p = 2) { - return n < Math.pow(10, p - 1) ? `0${n}` : n; - }, - - getClockTime() { - const date = new Date(); - return `${date.getFullYear()}-${this.formatFixed2(date.getMonth() + 1)}-${this.formatFixed2(date.getDate())} ${this.formatFixed2(date.getHours())}:${this.formatFixed2(date.getMinutes())}:${this.formatFixed2(date.getSeconds())}.${this.formatFixed2(date.getMilliseconds(), 3)}`; }, onP2PDevNotify({ detail }) { - if (!this.data.p2pReady) { - return; - } switch (detail.type) { case XP2PDevNotify_SubType.P2P: this.setData({ @@ -154,11 +142,11 @@ Component({ }); break; case XP2PDevNotify_SubType.Peerlist: - this.setData({ peerlist: `${this.getClockTime()} - ${detail.detail}` }); + this.setData({ peerlist: `${getClockTime()} - ${detail.detail}` }); break; case XP2PDevNotify_SubType.Subscribe: console.log(`[${this.data.innerId}]`, 'onP2PDevNotify', detail.type, detail.detail); - this.setData({ subscribeLog: `${this.data.subscribeLog}${this.getClockTime()} - ${detail.detail}\n` }); + this.setData({ subscribeLog: `${this.data.subscribeLog}${getClockTime()} - ${detail.detail}\n` }); break; case XP2PDevNotify_SubType.Err: this.setData({ errLog: `${this.data.errLog} ${detail.detail.err}\n`}); diff --git a/demo/miniprogram/components/iot-p2p-player-server/player.wxml b/demo/miniprogram/components/iot-p2p-player-server/player.wxml index 72d6be9..066f467 100644 --- a/demo/miniprogram/components/iot-p2p-player-server/player.wxml +++ b/demo/miniprogram/components/iot-p2p-player-server/player.wxml @@ -7,7 +7,7 @@ flvUrl="{{flvUrl}}" codeUrl="{{codeUrl}}" onlyp2p="{{onlyp2p}}" - bind:p2pStateChange="onP2PStateChange" + bind:streamStateChange="onStreamStateChange" bind:p2pDevNotify="onP2PDevNotify" bind:playError="passEvent" /> diff --git a/demo/miniprogram/components/iot-p2p-voice/voice.js b/demo/miniprogram/components/iot-p2p-voice/voice.js index 0b7e29d..e1651af 100644 --- a/demo/miniprogram/components/iot-p2p-voice/voice.js +++ b/demo/miniprogram/components/iot-p2p-voice/voice.js @@ -41,7 +41,6 @@ Component({ }, data: { innerId: '', - isDetached: false, // 语音对讲 needPusher: false, // attached 时根据 intercomType 设置 @@ -154,6 +153,7 @@ Component({ // 渲染无关,不放在data里,以免影响性能 this.userData = { + isDetached: false, timestamps: null, steps: null, pusher: null, @@ -186,7 +186,7 @@ Component({ }, detached() { console.log(`[${this.data.innerId}]`, '==== detached'); - this.setData({ isDetached: true }); + this.userData.isDetached = true; this.stopVoice(); console.log(`[${this.data.innerId}]`, '==== detached end'); }, @@ -199,13 +199,13 @@ Component({ }, methods: { showToast(content) { - !this.data.isDetached && wx.showToast({ + !this.userData.isDetached && wx.showToast({ title: content, icon: 'none', }); }, showModal(params) { - !this.data.isDetached && wx.showModal(params); + !this.userData.isDetached && wx.showModal(params); }, getPusherComp() { const pusher = this.selectComponent(`#${this.data.pusherId}`); @@ -324,7 +324,7 @@ Component({ }); }, async startVoice(e) { - if (this.data.isDetached) { + if (this.userData.isDetached) { return; } @@ -369,7 +369,7 @@ Component({ this.doStartVoice(); }, async doStartVoice() { - if (this.data.isDetached) { + if (this.userData.isDetached) { return; } @@ -418,7 +418,7 @@ Component({ return; } - if (this.data.isDetached || !this.data.voiceState) { + if (this.userData.isDetached || !this.data.voiceState) { // 已经stop了 return; } @@ -573,7 +573,7 @@ Component({ }); }, stopVoice() { - if (this.data.isDetached) { + if (this.userData.isDetached) { return; } diff --git a/demo/miniprogram/components/mock-p2p-player/player.js b/demo/miniprogram/components/mock-p2p-player/player.js index 26e1185..7e2ee13 100644 --- a/demo/miniprogram/components/mock-p2p-player/player.js +++ b/demo/miniprogram/components/mock-p2p-player/player.js @@ -14,7 +14,6 @@ Component({ created() { // 渲染无关,不放在data里,以免影响性能 this.userData = { - isDetached: false, ctx: null, chunkCount: 0, totalBytes: 0, @@ -25,7 +24,6 @@ Component({ this.prepare(); }, detached() { - this.userData.isDetached = true; this.clearStreamData(); if (this.userData?.ctx?.isPlaying) { this.userData.ctx.stop(); @@ -123,12 +121,8 @@ Component({ }); setTimeout(() => { - if (this.userData.isDetached) { - return; - } const fieldName = `${this.properties.type}Context`; this.triggerEvent('playerReady', { - msg: 'mockPlayer', [fieldName]: livePlayerContext, playerExport: { setHeaders: this.setHeaders.bind(this), diff --git a/demo/miniprogram/config/devices.js b/demo/miniprogram/config/devices.js index be0b9d5..00e3bc4 100644 --- a/demo/miniprogram/config/devices.js +++ b/demo/miniprogram/config/devices.js @@ -1,7 +1,6 @@ /** * device属性说明: * showInHomePageBtn: boolean 是否显示在首页大按钮 - * showInHomePageNav: boolean 是否显示在首页导航 * * 下面这些会自动填到player组件的输入框里,也可以手动修改 * productId: string 摄像头的 productId @@ -18,12 +17,18 @@ // 这些是预置的ipc设备 const devices = { + og_test: { + showInHomePageBtn: true, + productId: 'WJPEXAPK6Y', + deviceName: 'M7L1_79437960_3', + xp2pInfo: 'XP2Pj3qiR5/jpOsnI7/fUAeK5GPi%2.3.15', + }, test_mjpg: { showInHomePageBtn: true, - productName: 'ac7916', - productId: 'SO1Z9Y787A', - deviceName: 'youzi_79972790_1', - xp2pInfo: 'XP2P4dDSc1VbFpls3QZAE+cEMg==%2.4.29m', + productName: 'MjpgLock', + productId: '65HUY1C739', + deviceName: 'yzlock_84641797_1', + xp2pInfo: 'XP2PfLGXhL7HQJt5hcxgK/8xGQ==%2.4.33m', liveParams: 'action=live-audio&channel=0', liveMjpgParams: 'action=live-mjpg&channel=0', playbackParams: 'action=playback-audio&channel=0', @@ -33,18 +38,75 @@ const devices = { intercomType: 'Pusher', }, }, + 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', + }, test_lock: { showInHomePageBtn: true, productName: 'Lock', productId: '9L1S66FZ3Q', deviceName: 'z_83326880_1', - xp2pInfo: 'XP2P9b3HzHKvNSbg9GvqI9JuyQ==%2.4.31', + xp2pInfo: 'XP2P9b3HzHKvNSbc/BjtJOZehw==%2.4.31', liveParams: 'action=live&channel=0', playbackParams: 'action=playback&channel=0', options: { intercomType: 'Pusher', }, }, + test_android: { + showInHomePageBtn: 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', + }, + }, + 'of-2': { + showInHomePageBtn: true, + productId: '9L1S66FZ3Q', + 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: true, + productId: 'AQTV2839QJ', + deviceName: 'sp02_33925210_13', + xp2pInfo: 'XP2PYYAldyTto1racnyQNjcnvg==%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) => { diff --git a/demo/miniprogram/config/streams.js b/demo/miniprogram/config/streams.js index 2cba60c..5a2f31d 100644 --- a/demo/miniprogram/config/streams.js +++ b/demo/miniprogram/config/streams.js @@ -1,7 +1,6 @@ /** * stream属性说明: * showInHomePageBtn: boolean 是否显示在首页大按钮,产品用 - * showInHomePageNav: boolean 是否显示在首页导航,有onlyp2p入口,开发用 * * serverName: string 会根据serverName从serverMap里查询预置的server信息 * flvFile: string 视频流的 flvFile,可以带参数 @@ -35,7 +34,7 @@ const serverStreams = { // flvFile: '6e0b2be040a943489ef0b9bb344b96b8.hd.flv', // }, // xntpStream0: { - // showInHomePageNav: true, + // showInHomePageBtn: false, // serverName: 'xntpSvr', // flvFile: '6e0b2be040a943489ef0b9bb344b96b8.hd.flv', // }, diff --git a/demo/miniprogram/pages/index/index.js b/demo/miniprogram/pages/index/index.js index d59dca4..13eea04 100644 --- a/demo/miniprogram/pages/index/index.js +++ b/demo/miniprogram/pages/index/index.js @@ -37,14 +37,15 @@ Page({ // 这些是监控页入口 recentIPCItem: null, listBtn: [], - listNav: [], firstServerStream: null, firstIPCStream: null, + + // 选择的cfg + cfg: '', }, onLoad() { console.log('index: onLoad'); const listBtn = []; - const listNav = []; let firstServerStream = null; let firstIPCStream = null; for (const key in devices) { @@ -61,7 +62,6 @@ Page({ firstIPCStream = navItem; } } - item.showInHomePageNav && listNav.push(navItem); } for (const key in streams) { const item = streams[key]; @@ -77,9 +77,8 @@ Page({ firstServerStream = navItem; } } - item.showInHomePageNav && listNav.push(navItem); } - this.setData({ listBtn, listNav, firstServerStream, firstIPCStream }); + this.setData({ listBtn, firstServerStream, firstIPCStream }); this.refreshRecnetIPC(); }, @@ -100,8 +99,18 @@ Page({ onP2PModuleStateChange({ detail }) { console.log('index: onP2PModuleStateChange', detail); }, - gotoDemoPage(e) { - const { url } = e.currentTarget.dataset; - wx.navigateTo({ url }); + onClickCfg(e) { + const { cfg } = e.currentTarget.dataset; + wx.navigateTo({ url: `/pages/xp2p-demo-1vN/demo?cfg=${cfg}` }); + // 容易误退出,还是跳到播放页再input + // this.setData({ cfg }); + }, + onStartPlayer({ detail }) { + console.log('index: onStartPlayer', detail); + wx.navigateTo({ url: `/pages/xp2p-singleplayer/demo?json=${encodeURIComponent(JSON.stringify(detail))}` }); + this.setData({ cfg: '' }); + }, + onCancelInput() { + this.setData({ cfg: '' }); }, }); diff --git a/demo/miniprogram/pages/index/index.wxml b/demo/miniprogram/pages/index/index.wxml index ddfb5d1..3b7ee1d 100644 --- a/demo/miniprogram/pages/index/index.wxml +++ b/demo/miniprogram/pages/index/index.wxml @@ -1,55 +1,56 @@ - - - hostInfo: {{hostInfo}} - xp2pPluginInfo: {{xp2pPluginInfo}} - playerPluginInfo: {{playerPluginInfo}} - wxVersion: wx {{wxVersion}} / sdk {{wxSDKVersion}} - pluginVersion: xp2p {{xp2pVersion}} / p2p-player {{playerVersion}} - - - + + - - Recent - - - - List - - - - - - 跳转到 {{item.title}} - + + + hostInfo: {{hostInfo}} + xp2pPluginInfo: {{xp2pPluginInfo}} + playerPluginInfo: {{playerPluginInfo}} + wxVersion: wx {{wxVersion}} / sdk {{wxSDKVersion}} + pluginVersion: xp2p {{xp2pVersion}} / p2p-player {{playerVersion}} + + + + + + Recent + + + + List + + + 跳转到 Log 管理页 + 跳转到 下载管理页 + 跳转到 录像管理页-flv + 跳转到 录像管理页-mjpg + 跳转到 语音管理页 + 跳转到 LivePlayer 测试页 + 跳转到 Video 测试页 + + - - 跳转到 Log 管理页 - 跳转到 下载管理页 - 跳转到 录像管理页-flv - 跳转到 录像管理页-mjpg - 跳转到 语音管理页 - 跳转到 LivePlayer 测试页 - 跳转到 Video 测试页 - - diff --git a/demo/miniprogram/pages/xp2p-demo-1vN/demo.js b/demo/miniprogram/pages/xp2p-demo-1vN/demo.js index 8d0c93e..a8b12aa 100644 --- a/demo/miniprogram/pages/xp2p-demo-1vN/demo.js +++ b/demo/miniprogram/pages/xp2p-demo-1vN/demo.js @@ -14,11 +14,15 @@ Page({ // 这些是控制player和p2p的 playerId: 'iot-p2p-player', - player: null, }, onLoad(query) { console.log('demo: onLoad', query); + this.userData = { + targetId: '', + player: null, + }; + const cfg = query.cfg || ''; this.setData({ cfg, @@ -35,9 +39,17 @@ Page({ this.hasExited = true; // 监控页关掉player就好,不用销毁 p2p 模块 - if (this.data.player) { - this.data.player.stopAll('auto'); // 按player内部属性来 - this.setData({ player: null }); + if (this.userData.player) { + console.log('demo: player.stopAll'); + this.userData.player.stopAll(); + this.userData.player = null; + } + + // 断开连接 + if (this.userData.targetId) { + console.log('demo: stopP2PService', this.userData.targetId); + xp2pManager.stopP2PService(this.userData.targetId); + this.userData.targetId = ''; } console.log('demo: checkReset when exit'); @@ -46,12 +58,30 @@ Page({ }, onStartPlayer({ detail }) { console.log('demo: onStartPlayer', detail); + this.userData.targetId = detail.targetId; + + // 开始连接 + console.log('demo: startP2PService', this.userData.targetId); + try { + xp2pManager.startP2PService(this.userData.targetId, { + url: detail.flvUrl, + productId: detail.productId, + deviceName: detail.deviceName, + xp2pInfo: detail.xp2pInfo, + liveStreamDomain: detail.liveStreamDomain, + codeUrl: detail.codeUrl, + }).catch(() => undefined); // 只是提前连接,不用处理错误 + } catch (err) { + console.error('demo: startP2PService err', err); + } + + console.log('demo: create player'); this.setData(detail, () => { const player = this.selectComponent(`#${this.data.playerId}`); if (player) { - this.setData({ player }); + this.userData.player = player; } else { - console.error('create player error', this.data.playerId); + console.error('demo: create player error', this.data.playerId); } }); }, @@ -66,7 +96,7 @@ Page({ if (isFatalError) { // 致命错误,需要reset的全部reset xp2pManager.checkReset(); - this.data.player.reset(); + this.userData.player?.reset(); } }, }); diff --git a/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxml b/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxml index d15e7c9..01047ec 100644 --- a/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxml +++ b/demo/miniprogram/pages/xp2p-demo-1vN/demo.wxml @@ -1,11 +1,11 @@ - + - + ... diff --git a/demo/miniprogram/pages/xp2p-singleplayer/demo.js b/demo/miniprogram/pages/xp2p-singleplayer/demo.js index 3a10bea..d2b5b90 100644 --- a/demo/miniprogram/pages/xp2p-singleplayer/demo.js +++ b/demo/miniprogram/pages/xp2p-singleplayer/demo.js @@ -1,6 +1,10 @@ -import { getPlayerProperties } from '../../utils'; import { getXp2pManager } from '../../lib/xp2pManager'; +// 覆盖 console +const app = getApp(); +const oriConsole = app.console; +const console = app.logger || oriConsole; + const xp2pManager = getXp2pManager(); Page({ @@ -9,73 +13,117 @@ Page({ p2pMode: '', targetId: '', flvUrl: '', + mjpgFile: '', productId: '', deviceName: '', xp2pInfo: '', + liveStreamDomain: '', codeUrl: '', - onlyp2p: false, + options: null, + onlyp2pMap: null, // 这些是控制player和p2p的 playerId: 'iot-p2p-player', playerTitle: '', - player: null, }, onLoad(query) { console.log('singleplayer: onLoad', query); - const cfg = query.cfg || ''; - const onlyp2p = !!parseInt(query.onlyp2p, 10); - const opts = { - onlyp2p, + this.userData = { + targetId: '', + player: null, }; - const newData = getPlayerProperties(cfg, opts); - if (!newData) { + let info; + try { + const json = decodeURIComponent(query.json); + info = JSON.parse(json); + } catch (err) { + console.error('singleplayer: parse cfg error', err); + }; + if (!info?.targetId) { wx.showModal({ - content: `invalid cfg ${cfg}`, + content: 'invalid cfg', showCancel: false, }); return; } - if (newData.p2pMode === 'ipc') { - newData.playerTitle = `${newData.productId}/${newData.deviceName}`; + if (info.p2pMode === 'ipc') { + info.playerTitle = `${info.productId}/${info.deviceName}`; } - console.log('singleplayer: setData', newData); - this.setData(newData, () => { - const player = this.selectComponent(`#${this.data.playerId}`); - if (player) { - this.setData({ player }); - } else { - console.error('create player error', this.data.playerId); - } - }); + this.startPlayer(info); + }, + onShow() { + console.log('singleplayer: onShow'); + }, + onHide() { + console.log('singleplayer: onHide'); }, onUnload() { console.log('singleplayer: onUnload'); this.hasExited = true; // 监控页关掉player就好,不用销毁 p2p 模块 - if (this.data.player) { - this.data.player.stopAll(); - this.setData({ player: null }); + if (this.userData.player) { + console.log('singleplayer: player.stopAll'); + this.userData.player.stopAll(); + this.userData.player = null; + } + + // 断开连接 + if (this.userData.targetId) { + console.log('singleplayer: stopP2PService', this.userData.targetId); + xp2pManager.stopP2PService(this.userData.targetId); + this.userData.targetId = ''; } console.log('singleplayer: checkReset when exit'); xp2pManager.checkReset(); + console.log('singleplayer: onUnload end'); + }, + startPlayer(detail) { + console.log('singleplayer: startPlayer', detail); + this.userData.targetId = detail.targetId; + + // 开始连接 + console.log('singleplayer: startP2PService', this.userData.targetId); + try { + xp2pManager.startP2PService(this.userData.targetId, { + url: detail.flvUrl, + productId: detail.productId, + deviceName: detail.deviceName, + xp2pInfo: detail.xp2pInfo, + liveStreamDomain: detail.liveStreamDomain, + codeUrl: detail.codeUrl, + }).catch(() => undefined); // 只是提前连接,不用处理错误 + } catch (err) { + console.error('singleplayer: startP2PService err', err); + } + + console.log('singleplayer: create player'); + this.setData(detail, () => { + const player = this.selectComponent(`#${this.data.playerId}`); + if (player) { + this.userData.player = player; + } else { + console.error('singleplayer: create player error', this.data.playerId); + } + }); }, - onPlayError({ detail, currentTarget }) { - console.log('singleplayer: onPlayError', currentTarget.id, detail.errType, 'isFatalError', detail.isFatalError, detail); + onPlayError({ detail }) { + console.error('singleplayer: 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: () => { + console.log(`singleplayer: error modal complete, isFatalError ${isFatalError}`); if (isFatalError) { - // demo简单点,直接退出,注意 onUnload 时可能需要reset插件 - // 如果不想退出,在这里reset插件(如果需要的话),然后重新创建player组件 - !this.hasExited && wx.navigateBack(); + // 致命错误,需要reset的全部reset + xp2pManager.checkReset(); + this.userData.player?.reset(); } }, }); diff --git a/demo/miniprogram/pages/xp2p-singleplayer/demo.wxml b/demo/miniprogram/pages/xp2p-singleplayer/demo.wxml index e0ddeba..dde341e 100644 --- a/demo/miniprogram/pages/xp2p-singleplayer/demo.wxml +++ b/demo/miniprogram/pages/xp2p-singleplayer/demo.wxml @@ -1,15 +1,18 @@ - {{playerTitle || targetId}} diff --git a/demo/miniprogram/project.config.json b/demo/miniprogram/project.config.json deleted file mode 100644 index 7fc5aaf..0000000 --- a/demo/miniprogram/project.config.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "appid": "wx9e8fbc98ceac2628", - "projectname": "iotvideo-xp2p-github", - "compileType": "miniprogram", - "libVersion": "2.19.3", - "setting": { - "urlCheck": false, - "es6": true, - "enhance": true, - "postcss": false, - "preloadBackgroundData": false, - "minified": true, - "newFeature": true, - "coverView": true, - "nodeModules": true, - "autoAudits": false, - "showShadowRootInWxmlPanel": true, - "scopeDataCheck": false, - "uglifyFileName": false, - "checkInvalidKey": true, - "checkSiteMap": true, - "uploadWithSourceMap": true, - "compileHotReLoad": false, - "useMultiFrameRuntime": true, - "useApiHook": true, - "useApiHostProcess": true, - "babelSetting": { - "ignore": [], - "disablePlugins": [], - "outputPath": "" - }, - "useIsolateContext": false, - "packNpmManually": false, - "packNpmRelationList": [], - "minifyWXSS": true, - "minifyWXML": true, - "ignoreUploadUnusedFiles": true, - "lazyloadPlaceholderEnable": false, - "disableUseStrict": false, - "showES6CompileOption": false, - "useCompilerPlugins": false, - "useStaticServer": true - }, - "simulatorType": "wechat", - "simulatorPluginLibVersion": {}, - "condition": {}, - "packOptions": { - "ignore": [], - "include": [] - }, - "editorSetting": { - "tabIndent": "insertSpaces", - "tabSize": 2 - }, - "description": "项目配置文件,详见文档: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 a58d60c..847f8bf 100644 --- a/demo/miniprogram/utils.js +++ b/demo/miniprogram/utils.js @@ -54,6 +54,11 @@ export const toDateTimeString = (date) => `${toDateString(date)} ${toTimeString( export const toDateTimeMsString = (date) => `${toDateString(date)} ${toTimeMsString(date)}`; export const toDateTimeFilename = (date) => `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; +export const getClockTime = () => { + const date = new Date(); + return toDateTimeMsString(date); +}; + export const isPeername = (xp2pInfo) => /^\w+$/.test(xp2pInfo) && !/^XP2P/.test(xp2pInfo); // 兼容直接填 peername 的情况

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