/** * 桃夭 */ import { TaoyaoClient } from "./TaoyaoClient.js"; import * as mediasoupClient from "mediasoup-client"; import { protocol, defaultAudioConfig, defaultVideoConfig, defaultRTCPeerConnectionConfig, } from "./Config.js"; /** * 信令通道 */ const signalChannel = { // 桃夭 taoyao: null, // 通道 channel: null, // 地址 address: null, // 回调 callback: null, // 回调事件 callbackMapping: new Map(), // 心跳时间 heartbeatTime: 30 * 1000, // 心跳定时器 heartbeatTimer: null, // 是否重连 reconnection: true, // 重连定时器 reconnectTimer: null, // 防止重复重连 lockReconnect: false, // 当前重连时间 connectionTimeout: 5 * 1000, // 最小重连时间 minReconnectionDelay: 5 * 1000, // 最大重连时间 maxReconnectionDelay: 60 * 1000, /** * 心跳 */ heartbeat() { const self = this; if (self.heartbeatTimer) { clearTimeout(self.heartbeatTimer); } self.heartbeatTimer = setTimeout(async function () { if (self.channel && self.channel.readyState === WebSocket.OPEN) { const battery = await navigator.getBattery(); // TODO:信号强度 self.push( protocol.buildMessage("client::heartbeat", { signal: 100, battery: battery.level * 100, charging: battery.charging, }) ); self.heartbeat(); } else { console.warn("发送心跳失败:", self.address); } }, self.heartbeatTime); }, /** * 连接 * * @param {*} address 地址 * @param {*} callback 回调 * @param {*} reconnection 是否重连 * * @returns Promise */ async connect(address, callback, reconnection = true) { const self = this; if (self.channel && self.channel.readyState === WebSocket.OPEN) { return new Promise((resolve, reject) => { resolve(self.channel); }); } self.address = address; self.callback = callback; self.reconnection = reconnection; return new Promise((resolve, reject) => { console.debug("连接信令通道:", self.address); self.channel = new WebSocket(self.address); self.channel.onopen = async function () { console.debug("打开信令通道:", self.address); // 注册终端 // TODO:信号强度 const battery = await navigator.getBattery(); self.push( protocol.buildMessage("client::register", { name: "桃夭Web", clientId: self.taoyao.clientId, clientType: "WEB", signal: 100, battery: battery.level * 100, charging: battery.charging, username: self.taoyao.username, password: self.taoyao.password, }) ); // 重置时间 self.connectionTimeout = self.minReconnectionDelay; // 开始心跳 self.heartbeat(); // 成功回调 resolve(self.channel); }; self.channel.onclose = async function () { console.warn("信令通道关闭:", self.channel); if (self.reconnection) { self.reconnect(); } // 不要失败回调 }; self.channel.onerror = async function (e) { console.error("信令通道异常:", self.channel, e); if (self.reconnection) { self.reconnect(); } // 不要失败回调 }; /** * 回调策略: * 1. 如果注册请求回调,同时执行结果返回true不再执行后面所有回调。 * 2. 如果注册全局回调,同时执行结果返回true不再执行后面所有回调。 * 3. 如果前面所有回调没有返回true执行默认回调。 */ self.channel.onmessage = function (e) { console.debug("信令通道消息:", e.data); let done = false; const message = JSON.parse(e.data); // 请求回调 if (self.callbackMapping.has(message.header.id)) { try { done = self.callbackMapping.get(message.header.id)(message); } finally { self.callbackMapping.delete(message.header.id); } } // 全局回调 if (!done && self.callback) { done = self.callback(message); } // 默认回调 if (!done) { self.defaultCallback(message); } }; }); }, /** * 重连 */ reconnect() { const self = this; if ( self.lockReconnect || (self.channel && self.channel.readyState === WebSocket.OPEN) ) { return; } self.lockReconnect = true; if (self.reconnectTimer) { clearTimeout(self.reconnectTimer); } // 定时重连 self.reconnectTimer = setTimeout(function () { console.info("信令通道重连:", self.address); self.connect(self.address, self.callback, self.reconnection); self.lockReconnect = false; }, self.connectionTimeout); self.connectionTimeout = Math.min( self.connectionTimeout + self.minReconnectionDelay, self.maxReconnectionDelay ); }, /** * 异步请求 * * @param {*} message 消息 * @param {*} callback 回调 */ push(message, callback) { const self = this; // 注册回调 if (callback) { self.callbackMapping.set(message.header.id, callback); } // 发送消息 try { self.channel.send(JSON.stringify(message)); } catch (error) { console.error("推送消息异常:", message, error); } }, /** * 同步请求 * * @param {*} message 消息 * * @returns Promise */ async request(message) { const self = this; return new Promise((resolve, reject) => { let done = false; // 注册回调 self.callbackMapping.set(message.header.id, (response) => { resolve(response); done = true; // 返回true不要继续执行回调 return true; }); // 发送消息 try { self.channel.send(JSON.stringify(message)); } catch (error) { console.error("请求消息异常:", message, error); } // 设置超时 setTimeout(() => { if (!done) { self.callbackMapping.delete(message.header.id); reject("请求超时", message); } }, 5000); }); }, /** * 关闭通道 */ close() { const self = this; self.reconnection = false; self.channel.close(); clearTimeout(self.heartbeatTimer); clearTimeout(self.reconnectTimer); }, /** * 默认回调 * * @param {*} message 消息 */ defaultCallback(message) { let self = this; console.debug("没有适配信令消息执行默认处理", message); switch (message.header.signal) { case "client::config": self.defaultClientConfig(message); break; case "client::reboot": self.defaultClientReboot(message); break; case "client::shutdown": self.defaultClientShutdown(message); break; case "client::register": console.info("终端注册成功"); break; case "platform::error": self.callbackError(message); break; } }, /** * 配置默认回调 * * @param {*} message 消息 */ defaultClientConfig(message) { const self = this; self.taoyao.audio = { ...defaultAudioConfig, ...message.body.media.audio }; self.taoyao.video = { ...defaultVideoConfig, ...message.body.media.video }; self.taoyao.webrtc = message.body.webrtc; console.debug( "终端配置", self.taoyao.audio, self.taoyao.video, self.taoyao.webrtc ); }, /** * 终端重启默认回调 * * @param {*} message 消息 */ defaultClientReboot(message) { console.info("重启终端"); location.reload(); }, /** * 终端重启默认回调 * * @param {*} message 消息 */ defaultClientShutdown(message) { console.info("关闭终端"); window.close(); }, }; /** * 桃夭 */ class Taoyao { // 房间标识 roomId; // 终端标识 clientId; // 信令地址 host; // 信令端口 port; // 信令帐号 username; // 信令密码 password; // 回调事件 callback; // 音频媒体配置 audio; // 视频媒体配置 video; // WebRTC配置 webrtc; // 发送信令 push; // 请求信令 request; // 本地终端 localClient; // 远程终端 remoteClients = new Map(); // 信令通道 signalChannel; // 发送媒体通道 sendTransport; // 接收媒体通道 recvTransport; // 媒体设备 mediasoupDevice; // 是否消费 consume; // 是否生产 produce; // 是否生产音频 audioProduce; // 是否生成视频 videoProduce; // 强制使用TCP forceTcp; // 使用数据通道 useDataChannel; // 音频生产者 audioProducer; // 视频生产者 videoProducer; // 数据生产者 dataChannnelProducer; // 媒体消费者 consumers = new Map(); // 数据消费者 dataConsumers = new Map(); constructor({ roomId, clientId, host, port, username, password, consume = true, produce = true, audioProduce = true, videoProduce = true, forceTcp = false, useDataChannel = true, }) { this.roomId = roomId; this.clientId = clientId; this.host = host; this.port = port; this.username = username; this.password = password; this.consume = consume; this.produce = produce; this.audioProduce = produce && audioProduce; this.videoProduce = produce && videoProduce; this.forceTcp = forceTcp; this.useDataChannel = useDataChannel; } /** * 打开信令通道 * * @param {*} callback * * @returns */ async buildSignal(callback) { const self = this; self.callback = callback; self.signalChannel = signalChannel; signalChannel.taoyao = self; // 不能直接this.push = this.signalChannel.push这样导致this对象错误 self.push = function (data, pushCallback) { self.signalChannel.push(data, pushCallback); }; self.request = async function (data) { return await self.signalChannel.request(data); }; return self.signalChannel.connect( `wss://${self.host}:${self.port}/websocket.signal`, callback ); } /** * 错误回调 */ callbackError(message, error) { const self = this; if (!self.callback) { console.warn("没有注册回调:", message, error); } // 错误回调 self.callback( protocol.buildMessage("platform::error", { message }, -9999), error ); } /** * 创建房间 */ async create(room) { const self = this; const response = await self.request( protocol.buildMessage("room::create", room) ); return response.body; } async enter(roomId) { const self = this; if (!roomId) { this.callbackError("无效房间"); return; } self.roomId = roomId; self.mediasoupDevice = new mediasoupClient.Device(); const response = await self.request( protocol.buildMessage("media::router::rtp::capabilities", { roomId: self.roomId, }) ); const routerRtpCapabilities = response.body.rtpCapabilities; await self.mediasoupDevice.load({ routerRtpCapabilities }); await self.request( protocol.buildMessage("room::enter", { roomId: roomId, rtpCapabilities: self.consume ? self.mediasoupDevice.rtpCapabilities : undefined, sctpCapabilities: self.consume && self.useDataChannel ? self.mediasoupDevice.sctpCapabilities : undefined, }) ); } /** * TODO:共享 navigator.mediaDevices.getDisplayMedia(); * 生产媒体 * TODO:验证API试试修改媒体 * audioTrack.getSettings * audioTrack.getCapabilities * audioTrack.applyCapabilities */ async produceMedia() { const self = this; if (!self.roomId) { this.callbackError("无效房间"); return; } // 检查设备 self.checkDevice(); // 释放资源 self.closeMedia(); // TODO:暂时不知道为什么? { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const audioTrack = stream.getAudioTracks()[0]; audioTrack.enabled = false; setTimeout(() => audioTrack.stop(), 120000); } if (self.produce) { const response = await self.request( protocol.buildMessage("media::transport::webrtc::create", { roomId: self.roomId, forceTcp: self.forceTcp, producing: true, consuming: false, sctpCapabilities: self.useDataChannel ? self.mediasoupDevice.sctpCapabilities : undefined, }) ); const { transportId, iceParameters, iceCandidates, dtlsParameters, sctpParameters, } = response.body; self.sendTransport = self.mediasoupDevice.createSendTransport({ id: transportId, iceCandidates, iceParameters, dtlsParameters: { ...dtlsParameters, role: "auto", }, sctpParameters, // TODO:iceservers iceServers: [], // Google配置 proprietaryConstraints: { optional: [{ googDscp: true }], }, additionalSettings: // TODO:加密解密 { encodedInsertableStreams: false }, }); self.sendTransport.on( "connect", ({ dtlsParameters }, callback, errback) => { self .request( protocol.buildMessage("media::transport::webrtc::connect", { roomId: self.roomId, transportId: self.sendTransport.id, dtlsParameters, }) ) .then(callback) .catch(errback); } ); self.sendTransport.on( "produce", async ({ kind, appData, rtpParameters }, callback, errback) => { try { const { producerId } = await self.request( protocol.buildMessage("media::produce", { kind, roomId: self.roomId, appData, transportId: self.sendTransport.id, rtpParameters, }) ); callback({ id: producerId }); } catch (error) { errback(error); } } ); self.sendTransport.on( "producedata", async ( { label, protocol, appData, sctpStreamParameters }, callback, errback ) => { try { const { id } = await self.request( protocol.buildMessage("media::produceData", { label, appData, protocol, transportId: self.sendTransport.id, sctpStreamParameters, }) ); callback({ id }); } catch (error) { errback(error); } } ); } if (this.consume) { const self = this; const response = await self.request( protocol.buildMessage("media::transport::webrtc::create", { roomId: self.roomId, forceTcp: self.forceTcp, producing: false, consuming: true, sctpCapabilities: self.useDataChannel ? self.mediasoupDevice.sctpCapabilities : undefined, }) ); const { transportId, iceCandidates, iceParameters, dtlsParameters, sctpParameters, } = response.body; self.recvTransport = self.mediasoupDevice.createRecvTransport({ id: transportId, iceParameters, iceCandidates, dtlsParameters: { ...dtlsParameters, // Remote DTLS role. We know it's always 'auto' by default so, if // we want, we can force local WebRTC transport to be 'client' by // indicating 'server' here and vice-versa. role: "auto", }, iceServers: [], sctpParameters, additionalSettings: { // TODO:加密解密 encodedInsertableStreams: false, }, }); self.recvTransport.on( "connect", ( { dtlsParameters }, callback, errback // eslint-disable-line no-shadow ) => { self .request( protocol.buildMessage("media::transport::webrtc::connect", { transportId: this.recvTransport.id, dtlsParameters, }) ) .then(callback) .catch(errback); } ); } this.produceAudio(); this.produceVideo(); } /** * 生产音频 * TODO:重复点击 */ async produceAudio() { const self = this; if (self.audioProduce && self.mediasoupDevice.canProduce("audio")) { if (this.audioProducer) { return; } let track; try { console.debug("打开麦克风"); // TODO:设置配置 const stream = await navigator.mediaDevices.getUserMedia({ audio: true, }); track = stream.getAudioTracks()[0]; this.audioProducer = await this.sendTransport.produce({ track, codecOptions: { opusStereo: 1, opusDtx: 1, }, // NOTE: for testing codec selection. // codec : this._mediasoupDevice.rtpCapabilities.codecs // .find((codec) => codec.mimeType.toLowerCase() === 'audio/pcma') }); // TODO:加密解密 // if (this._e2eKey && e2e.isSupported()) { // e2e.setupSenderTransform(this._micProducer.rtpSender); // } this.audioProducer.on("transportclose", () => { this.audioProducer = null; }); this.audioProducer.on("trackended", () => { console.warn("audio producer trackended", this.audioProducer); this.closeAudioProducer().catch(() => {}); }); } catch (error) { self.callbackError("麦克风打开异常", error); if (track) { track.stop(); } } } else { self.callbackError("麦克风打开失败"); } } async closeAudioProducer() { console.debug("closeAudioProducer()"); if (!this.audioProducer) { return; } this.audioProducer.close(); try { await this.request( protocol.buildMessage("media::producer::close", { producerId: this.audioProducer.id, }) ); } catch (error) { console.error("关闭麦克风异常", error); } this.audioProducer = null; } async pauseAudioProducer() { console.debug("静音麦克风"); this.audioProducer.pause(); try { await this.request( protocol.buildMessage("media::producer::pause", { producerId: this.audioProducer.id, }) ); } catch (error) { console.error("静音麦克风异常", error); // TODO:异常调用回调 } } async resumeAudioProducer() { console.debug("恢复麦克风"); this.audioProducer.resume(); try { await this.request( protocol.buildMessage("media::producer::resume", { producerId: this.audioProducer.id, }) ); } catch (error) { console.error("恢复麦克风异常", error); } } /** * 生产视频 * TODO:重复点击 */ async produceVideo() {} /** * 验证设备 */ async checkDevice() { const self = this; if ( self.produce && navigator.mediaDevices && navigator.mediaDevices.getUserMedia && navigator.mediaDevices.enumerateDevices ) { let audioEnabled = false; let videoEnabled = false; (await navigator.mediaDevices.enumerateDevices()).forEach((v) => { console.debug("终端媒体设备", v, v.kind, v.label); switch (v.kind) { case "audioinput": audioEnabled = true; break; case "videoinput": videoEnabled = true; break; default: console.debug("没有适配设备", v.kind, v.label); break; } }); if (!audioEnabled && self.audioProduce) { self.callbackError("没有音频媒体设备"); } if (!videoEnabled && self.videoProduce) { self.callbackError("没有视频媒体设备"); } } else { self.callbackError("没有媒体设备"); } } async restartIce() { const self = this; try { if (self.sendTransport) { const response = await self.request("media::ice::restart", { roomId: self.roomId, transportId: self.sendTransport.id, }); const iceParameters = response.data.iceParameters; await self.sendTransport.restartIce({ iceParameters }); } if (self.recvTransport) { const response = await self.request("media::ice::restart", { roomId: self.roomId, transportId: self.recvTransport.id, }); const iceParameters = response.data.iceParameters; await self.recvTransport.restartIce({ iceParameters }); } } catch (error) { self.callbackError("重启ICE失败", error); } } /** * 关闭媒体 */ closeMedia = function () { let self = this; if (self.sendTransport) { self.sendTransport.close(); } if (self.recvTransport) { self.recvTransport.close(); } }; /** * 关闭 */ close = function () { let self = this; self.closeMedia(); if (self.signalChannel) { self.signalChannel.close(); } }; } export { Taoyao };