import { PlayerEvents, VideoStatus, externalEmitter, internalEmitter } from "../emitter"; import { IWebrtcMediaStreamSimple } from "../type"; import { isFireFox } from "../utils"; import SignalSimple from "./signal-simple"; interface IDisplayStatus { packetLossRate?: number; packetsLost?: undefined|number; delay?: number|undefined; frameAvgDecodeTime?: number; jitter: number; bytesPerSec: number; } export default class WebRtcMediaStreamerSimple { private _video: HTMLVideoElement; private _peerConnection?: RTCPeerConnection; private _pcConfig?: any; private _signal?: SignalSimple; private _controlChannel?: RTCDataChannel; private buildMediaOfferMessage: (desc: RTCSessionDescriptionInit) => any; private buildMediaCandidateMessage: (candidate: any) => any; private buildJoinRoomMessage: () => any; private readonly _earlyCandidates: RTCIceCandidate[]; private _keyFrame?: any; private _getStatusTimer: number; private _mediaRecorder?: MediaRecorder; private _recordChunks: any[]; get mediaRecorder() { return this._mediaRecorder; } public constructor(option: IWebrtcMediaStreamSimple) { this._video = option.videoElement; this._pcConfig = option?.iceConfig; this._earlyCandidates = []; this._keyFrame = option?.keyFrame; this._getStatusTimer = 0; this._recordChunks = []; this.buildMediaOfferMessage = option.buildMediaOfferMessage.bind(this); this.buildMediaCandidateMessage = option.buildMediaCandidateMessage.bind(this); this.buildJoinRoomMessage = option.buildJoinRoomMessage.bind(this); } public controlChannel() { return this._controlChannel; } public peerConnection() { return this._peerConnection; } // 设置信令 public setSignal = (signal: SignalSimple) => { this._signal = signal; } public sendControlChannelMessage(msg: any) { const msgStr = JSON.stringify(msg); this._controlChannel?.send(msgStr); } public cleanMediaRecorder() { this.clearSetStatusTimer(); this._mediaRecorder?.removeEventListener('start', this._recordStart); this._mediaRecorder?.removeEventListener('stop', this._recordStop); this._mediaRecorder = undefined; } // gatherCandidate作为CandidateFromWeb然后由信令发送 private _gatherCandidate = (event: RTCPeerConnectionIceEvent) => { if (event.candidate && this._peerConnection) { if (this._peerConnection.currentRemoteDescription) { this._signal && this._signal?.sendMsg(this.buildMediaCandidateMessage(event.candidate)); } else { this._earlyCandidates.push(event.candidate); } } else { console.log('RTCStreamer: IceCandidate 发送完毕'); } }; // 创建发送Offer private _handleCreateOffer() { this._peerConnection && this._peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true, }).then( sessionDescription => { this._peerConnection && this._peerConnection .setLocalDescription(sessionDescription) .then(() => { this._signal && this._signal?.sendMsg(this.buildMediaOfferMessage(sessionDescription)); }) .catch(error => console.error(`RTCStreamer: SetLocalDescription 失败${JSON.stringify(error, undefined, 2)}`) ); }, function (error) { console.error(`RTCStreamer: 创建Offer失败${JSON.stringify(error, undefined, 2)}`); } ); } // 请求关键帧 private _requestKeyFrame = () => { if (!this._peerConnection || this._peerConnection?.connectionState === 'closed') { console.error('RTCStreamer: PeerConnection 尚未创建或以关闭') return; } const dataChannel = this._peerConnection!.createDataChannel('KeyFrameChannel', { negotiated: false }); dataChannel.onopen = () => { console.log('RTCStreamer: KeyFrameChannel 创建成功'); this._controlChannel = dataChannel; let keyFrameId = window.setInterval(() => { dataChannel.send(JSON.stringify(this._keyFrame)); console.log('RTCStreamer: 请求关键帧'); }, 1000); this._video.addEventListener('loadeddata', () => { window.clearInterval(keyFrameId); keyFrameId = 0; }) }; } private _recordChunkStartPush = (event: any) => { this._recordChunks.push(event.data); } private _recordStart = () => { this._mediaRecorder?.addEventListener('dataavailable', this._recordChunkStartPush); } private _recordStop = () => { this._mediaRecorder?.removeEventListener('dataavailable', this._recordChunkStartPush); const blob = new Blob(this._recordChunks, { 'type' : 'video/mp4' }); this._recordChunks = []; const videoURL = URL.createObjectURL(blob); const downloadLink = document.createElement('a'); downloadLink.href = videoURL; downloadLink.download = 'recorded-video.mp4'; downloadLink.click(); URL.revokeObjectURL(videoURL); } private _createMediaRecorder = () => { let mediaStream: any = this._video.srcObject; if (!mediaStream && (this._video as any).captureStream) { mediaStream = (this._video as any).captureStream(); } if (mediaStream) { this._mediaRecorder = undefined; this._mediaRecorder = new MediaRecorder(mediaStream); this._mediaRecorder.addEventListener('start', this._recordStart); this._mediaRecorder.addEventListener('stop', this._recordStop); } } // onTrack回调后,设置videoSrcObject private _onAddStream = (event: RTCTrackEvent) => { console.log(`RTCStreamer: init with config ${JSON.stringify(this._peerConnection?.getConfiguration(), undefined, 2)}`) if (event.track.kind === 'audio') { console.log(`RTCStreamer: 添加audio轨道${JSON.stringify(event, undefined, 2)}`); } else if (event.track.kind === 'video') { console.log(`RTCStreamer: 添加video轨道${JSON.stringify(event, undefined, 2)}`); this._video.srcObject = event.streams[0]; this._setStatusTimer() } this._createMediaRecorder(); } // 初始化peerConnection并创建发送Offer public initPeer = () => { this._peerConnection = new RTCPeerConnection(); this._peerConnection?.addEventListener('connectionstatechange', (ev) => { if (this._peerConnection?.iceConnectionState === 'disconnected' || this._peerConnection?.iceConnectionState === 'failed' || this._peerConnection?.iceConnectionState === 'closed') { internalEmitter.emit('reset'); } }) this._peerConnection?.addTransceiver('video', { direction: 'recvonly', }); this._peerConnection?.addTransceiver('audio'); // console.log('add audio'); this._peerConnection.ontrack = this._onAddStream; // 如果需要createDataChannel, 需要在setDescription之前先create至少一个channel,否则后续一直无法open this._keyFrame && this._requestKeyFrame() if (this._pcConfig) { this._peerConnection.setConfiguration(this._pcConfig); } else { this._peerConnection.setConfiguration({ "iceServers":[{"urls":"turn:test.udt.woa.com:3478?transport=udp","credential":"udt@wetest","username":"udt"},{"urls":"turn:test.udt.woa.com:3478?transport=tcp","credential":"udt@wetest","username":"udt"},{"urls":"turn:119.29.18.254:3478?transport=udp","credential":"fEcGCPLqhYcj3JlYQnMgQpaLnXMhyQGF","username":"udt-public"}]}) } if (this._peerConnection?.iceConnectionState !== 'connected') { this._handleCreateOffer(); } this._peerConnection.onicecandidate = this._gatherCandidate; } // 收到Answer后,SetRemoteDescription并开始发送Candidate public handleReceiveAnswer = (descriptionInitDict: RTCSessionDescriptionInit) => { const descr = new RTCSessionDescription(descriptionInitDict); if (this._peerConnection?.iceConnectionState !== 'connected') { this._peerConnection && this._peerConnection .setRemoteDescription(descr) .then(() => { while (this._earlyCandidates.length) { const candidate = this._earlyCandidates.shift(); this._peerConnection && this._signal && this._signal?.sendMsg(this.buildMediaCandidateMessage(candidate)); } }) .catch(error => { console.error(`RTCStreamer: 设置 RemoteDescription 失败${JSON.stringify(error, undefined, 2)}`); }); } } // 收到CandidateFromDevice后,回调addIceCandidate public handleReceiveCandidate = (candidate: any) => { if (!candidate) { return; } const candidateNew = new RTCIceCandidate(candidate); this._peerConnection && this._peerConnection .addIceCandidate(candidateNew) .then(() => { console.log('RTCStreamer: 添加 Ice Candidate 成功'); }) .catch(error => { console.error(`RTCStreamer: 添加 Ice Candidate 失败 ${JSON.stringify(error, undefined, 2)}`); }); } _setStatusTimer() { let oldRTp: any; const hanldeNetStatus = (myDisplayStatus: IDisplayStatus) => { if (myDisplayStatus?.packetLossRate! < 5 && myDisplayStatus.delay! * 1000 < 80 && myDisplayStatus.jitter < 30) { return 2; } if (myDisplayStatus.delay! * 1000 < 150 && myDisplayStatus?.packetLossRate! < 10 && myDisplayStatus.jitter < 60) { return 1; } return 0; }; this._getStatusTimer = window.setInterval(async () => { const stats: any = await this._peerConnection?.getStats(); let packetsReceived = 0; let packetsLost: any; const myDisplayStatus: IDisplayStatus = { packetsLost: undefined, delay: isFireFox() ? 0.001 : undefined, frameAvgDecodeTime: 0, jitter: 0, bytesPerSec: 0, }; stats?.forEach((report: any) => { if (report.type === 'candidate-pair' && report.currentRoundTripTime !== undefined && report.state === 'succeeded' && report.bytesReceived !== 0 && report.writable) { myDisplayStatus.delay = report.currentRoundTripTime || report.totalRoundTripTime; } if (report.type === 'inbound-rtp' && report.kind === 'video') { if (oldRTp) { myDisplayStatus.bytesPerSec = (report.bytesReceived - oldRTp.bytesReceived) / 1024; } oldRTp = report; packetsReceived = report.packetsReceived; packetsLost = report.packetsLost; myDisplayStatus.jitter = report.jitter * 1000; myDisplayStatus.frameAvgDecodeTime = report.framesDecoded && report.totalDecodeTime ? 1000 * (report.totalDecodeTime / report.framesDecoded) : undefined; } }); let packetLossRate: number|undefined; if (packetsReceived !== 0) { packetLossRate = 100 * (packetsLost / packetsReceived); } externalEmitter.emit(PlayerEvents.STATUS, [{ label: VideoStatus.VIDEO_SPEED, value: myDisplayStatus.bytesPerSec?.toFixed(2) || 0, }, { label: VideoStatus.VIDEO_PACKAGE_LOST_RATE, value: packetLossRate === undefined ? 0 : packetLossRate > 100 ? (100).toFixed(2) : Math.abs(packetLossRate)?.toFixed(2), }, { label: VideoStatus.VIDEO_PACKAGE_LOST_DIVISION, value: `${packetsLost < 0 ? 0 : packetsLost}/${packetsReceived}`, }, { label: VideoStatus.VIDEO_DELAY, value: myDisplayStatus.delay === undefined ? undefined : myDisplayStatus.delay * 1000, }, { label: VideoStatus.VIDEO_JITTER, value: +(myDisplayStatus.jitter.toFixed(2)), }, { label: VideoStatus.VIDEO_NET_STATUS, value: hanldeNetStatus({ ...myDisplayStatus, packetLossRate }), }]); }, 1000); } public clearSetStatusTimer() { window.clearInterval(this._getStatusTimer); this._getStatusTimer = 0; } }