用 PeerJS 创建一个基于 WebRTC 的网页电话

自 2020 年起,视频通话就与我们的生活密不可分。尽管 Zoom&Google Meet 的邀请链接占据压倒性优势,但在这股强势的趋势下,还是有很多基于互联网的音视频 app 冲到了前沿。如果你读过我上一篇文章,就知道我一直在探索 WebRTC。

WebRTC 由一组 API 终端和多个协议组成,它不需要传统的服务器就可以将数据(音频、视频或其他任何形式)从一个对等点/设备发送到另一个对等点/设备。但 WebRTC 的使用和开发非常复杂,因此很多人在用它处理信令服务和调用正确的端点时会感到非常困惑。

好消息是 WebRTC 的框架 PeerJ 提取了所有 ice 和信令逻辑,开发人员可以专心开发应用程序的功能。 PeerJS 由客户端框架和服务器两部分组成,我们将同时使用这两部分,但大部分的工作是处理客户端代码。


搭建电话

这是一个中级教程,所以在开始之前,请熟悉以下内容:

  • Vanilla JavaScript
  • Node
  • Express
  • HTML

我只专注 JavaScript 方面的内容,所以大家可以直接复制 HTML CSS 文件,我不会过多介绍。在正式开始之前,请安装 node 和软件包管理器,我用的是 Yarn,但你们可以用 npm 或任何习惯使用的管理器。

如果你习惯看着代码和步骤一步一步地操作,那本教程可能刚好符合你的要求,因为我用代码编写了本教程,希望本教程能对大家有所帮助哦。


创建

那就开始吧。首先,运行 mkdir audio_app,然后运行 cd audio_app,最后,通过运行 yarn init 来创建一个新的 app。根据提示,为你的项目添加名称、版本和描述等。接下来,安装依赖项:

我们在对等服务器上使用 peer,用 PeerJS 访问 PeerJS API 和框架。安装完依赖项后,你的 package.json 应如下所示:

{
  "name": "audio_app",
  "version": "1.0.0",
  "description": "An audio app using WebRTC",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "Lola Odelola",
  "license": "MIT",
  "dependencies": {
    "express": "^4.17.1",
    "peer": "^0.5.3",
    "peerjs": "^1.3.1"
  }
}

另外,将我上文提到的 HTML CSS 文件复制到你的项目文件夹中,这才能完成创建。


构建服务器

服务器文件看起来是常规的 Express 服务器文件,区别在于 Peer 服务器不同。

大家需要在文件 const {ExpressPeerServer} = require('peer') 的基础上命令 peer 服务器,确保我们能够访问 peer 服务器。

然后,创建 peer 服务器:

const peerServer = ExpressPeerServer(server, {
    proxied: true,
    debug: true,
    path: '/myapp',
    ssl: {}
});

我们用之前创建的 ExpressPeerServer 对象来创建 peer 服务器,并向其传递一些选项。peer 服务器将处理 WebRTC 所需的信令,因为 peer 服务器为我们抽象了逻辑,所以我们不必担心 STUN / TURN 服务器或其他协议。

最后,调用 app.use(peerServer) ,让 app 使用 peerServer。完成的 server.js 还应包括服务器文件包含的其他的必要的依赖项,同时还要将 index.html 文件提供给根路径,完成时应如下所示:

const express = require("express");
const http = require('http');
const path = require('path');
const app = express();
const server = http.createServer(app);
const { ExpressPeerServer } = require('peer');
const port = process.env.PORT || "8000";

const peerServer = ExpressPeerServer(server, {
    proxied: true,
    debug: true,
    path: '/myapp',
    ssl: {}
});

app.use(peerServer);

app.use(express.static(path.join(__dirname)));

app.get("/", (request, response) => {
    response.sendFile(__dirname + "/index.html");
});

server.listen(port);
console.log('Listening on: ' + port);

大家应该能通过本地主机连接到自己的 app,我的 server.js 使用的是端口 8000(定义在第 7 行),但大家可能会用其他端口号,请在终端中运行 node . 并在浏览器中访问 localhost:8000,你会看到一个类似于下图的页面:

主页


大家感兴趣的部分

大家最感兴趣的部分来了——创建对等连接和调用逻辑。创建过程非常复杂,因此一定要注意。首先,创建一个 script.js 文件,该文件包含你的所有逻辑。

然后,创建一个有 ID 的 peer 对象,我们会用这个 ID 连接两个 peer,如果你没有创建 ID,系统会自动给 peer 分配一个。

const peer = new Peer(''+Math.floor(Math.random()*2**18).toString(36).padStart(4,0), {
    host: location.hostname,
    debug: 1,
    path: '/myapp'
});

然后,将 peer 附加到窗口,保证其是可访问的:

window.peer = peer;

在终端的另一个选项卡中,运行以下命令来启动 peer 服务器:

peerjs --port 443 --key peerjs --path /myapp

peer 创建成功之后,要获取浏览器的麦克风访问权限。我们将使用 navigator.MediaDevices 对象上的 getUserMedia 函 数,该函数是 Media Devices Web 界面的一部分。getUserMedia 终端需要一个能指定所需权限的 constraints 对象。 getUserMedia 是一个 promise 对象,会在成功解决该 promise 对象后返回一个 MediaStream 对象。这种情况下是我们在进行音频推流。如果 promise 对象未能成功解决,你要捕捉并显示错误。

function getLocalStream() {
    navigator.mediaDevices.getUserMedia({video: false, audio: true}).then( stream => {
        window.localStream = stream; // A
        window.localAudio.srcObject = stream; // B
        window.localAudio.autoplay = true; // C
    }).catch( err => {
        console.log("u got an error:" + err)
    });
}

A. window.localStream = stream:这里我们将 MediaStream 对象(在上一行中已分配给 stream )作为 localStream 附加到窗口。

B . window.localAudio.srcObject = stream:我们的 HTML 中有一个 ID 为 localAudio音频元素,将该元素的 src 属性设置为 promise 返回的 MediaStream 属性。

C . window.localAudio.autoplay = true:将音频元素的 autoplay 属性设置为自动播放。

调用 getLocalStream 函数并刷新浏览器时,会弹出以下权限:

弹出的权限

在授予权限之前先用耳机,这样在之后取消静音时,就不会收到任何反馈。如果没有弹出权限,就打开检查器查看一下是否有错误,另外,一定要确保你的 javascript 文件正确地链接到你的 index.html 文件。

所有内容放在一起应如下所示:

/* global Peer */


/**
 * Gets the local audio stream of the current caller
 * @param callbacks - an object to set the success/error behaviour
 * @returns {void}
 */


function getLocalStream() {
    navigator.mediaDevices.getUserMedia({video: false, audio: true}).then( stream => {
        window.localStream = stream;
        window.localAudio.srcObject = stream;
        window.localAudio.autoplay = true;
    }).catch( err => {
        console.log("u got an error:" + err)
    });
}

getLocalStream();

获得权限后,要确保每个用户都知道各自的 peer ID,以便能建立连接。peerJS 框架提供大量的事件监听器,我们可以在之前创建的 peer 上调用它们。因此,当 peer 打开时,就会显示其 ID:

peer.on('open', function () {
    window.caststatus.textContent = `Your device ID is: ${peer.id}`;
});

你正在用 ID caststatus(不是 connecting...替换 HTML 元素中的文本,你会看到 Your device ID is: <peer ID>
截屏:peer ID

你还可以在这里创建用于显示和隐藏各种内容的函数(之后会用到)。另外,你应该创建 showCallContentshowConnectedContent 函数,这些函数会显示呼叫按钮、挂断按钮和音频元素。

const audioContainer = document.querySelector('.call-container');
/**
 * Displays the call button and peer ID
 * @returns{void}
 */

function showCallContent() {
    window.caststatus.textContent = `Your device ID is: ${peer.id}`;
    callBtn.hidden = false;
    audioContainer.hidden = true;
}

/**
 * Displays the audio controls and correct copy
 * @returns{void}
 */

function showConnectedContent() {
    window.caststatus.textContent = `You're connected`;
    callBtn.hidden = true;
    audioContainer.hidden = false;
}

接下来,要确保用户能连接其 peer。要连接两个 peer 需要其中一个的 peer ID。你可以用 let 创建一个变量,然后将其分配到一个函数中,以便之后调用。

let code;
function getStreamCode() {
    code = window.prompt('Please enter the sharing code');
}

获取 peer ID 的便捷方法之一是使用窗口提示,你可以在收集创建连接所需的 peer ID 时使用此提示。

使用 peerJS 框架可以将 localPeer 连接到 remotePeer。PeerJS 有 connect 函数,可以获取一个 peer ID 来创建连接。

function connectPeers() {
    peer.connect(code)
}

连接创建后,使用 PeerJS 框架的 on(‘connection') 设置远端 peer 的 ID 并打开连接。该监听器的函数会接受一个 connection 对象,该对象是 DataConnection 对象的一个实例(是 WebRTC 的 DataChannel 的封装器),因此,在该函数内部,你需要将其分配给一个变量。同样,你需要在函数外部创建一个变量,以便之后进行分配。

let conn;
peer.on('connection', function(connection){
    conn = connection;
});

现在,给用户提供创建调用的功能。首先,获取 HTML 中定义的通话按钮:

const callBtn = document.querySelector(‘.call-btn’);`

当呼叫者单击“呼叫”时,你需要询问他们要呼叫的 peer ID(我们将其存储在 getStreamCodecode 中),然后用该代码创建连接。

callBtn.addEventListener('click', function(){
    getStreamCode();
    connectPeers();
    const call = peer.call(code, window.localStream); // A

    call.on('stream', function(stream) { // B
        window.remoteAudio.srcObject = stream; // C
        window.remoteAudio.autoplay = true; // D
        window.peerStream = stream; //E
        showConnectedContent(); //F
    });
})

A. const call = peer.call(code, window.localStream):使用我们先前所分配的 codewindow.localStream 来创建通话。注意:localStream 将会是用户的 localStream。因此,对于呼叫者 A,将是对方的推流,对于呼叫者 B,是他们自己的推流。

B. call.on('stream', function(stream) {:peerJS 给我们提供了一个 stream 事件,你可以在创建的 call 上使用。当一个调用开始推流时,你必须把来自呼叫方的远程推流分配到正确的 HTML 元素和窗口,你需要在这里执行此操作。

Ç. 这个匿名函数需要一个 MediaStream 对象作为参数,然后你要像之前一样将此参数设置为你窗口的 HTML。所以,你将获取远程音频元素,并将 src 属性分配给传递至该函数的推流。

D. 确保将元素的自动播放属性设置为 true。

E. 确保将窗口的 peerStream 设置为传递给函数的流。

F. 最后,要调用之前创建的 showConnectedContent 函数来显示正确的内容。

打开两个浏览器窗口并单击“呼叫”进行测试。你会看到以下内容:

两个浏览器并排运行,一个有询问代码的提示

如果你提交另一个 peer 的 ID,就能接通呼叫,但我们需要让其他浏览器有机会应答或拒绝该呼叫。

peerJS 框架下可以使用 .on('call') 事件,具体如下:

peer.on('call', function(call) {
    const answerCall = confirm("Do you want to answer?") // A

    if(answerCall){ 
        call.answer(window.localStream) // B
        showConnectedContent(); // C
        call.on('stream', function(stream) { // D
            window.remoteAudio.srcObject = stream;
            window.remoteAudio.autoplay = true;
            window.peerStream = stream;
        });
    } else {
        console.log("call denied"); // E
    }
});

浏览器提示是否接听呼叫

A. const answerCall = confirm("Do you want to answer"):首先,用确认提示来提醒用户接听。在用户屏幕上显示一个窗口(如图所示),用户可以选择“确定”或“取消”,该窗口会映射到一个被返回的布尔值。

B.call.answer(window.localStream):如果 answerCall 为 “true”,想在呼叫中调用 peerJS 的 answer 函数构建应答,需将其传递给本地推流。

C. showCallContent:与呼叫按钮事件监听器类似,用来确保被呼叫者看到正确的 HTML 内容。

D. call.on('stream', function(){...} 块中的所有内容都与呼叫按钮的事件监听器中的内容相同。在此处也添加它是为了让接听电话的人能更新浏览器。

E. 如果对方拒绝通话,我们将在控制台中记录一条消息。

我们即将大功告成。你现在拥有的代码已经可以创建呼叫并接听电话了。请刷新浏览器并进行测试。一定要确保两个浏览器均已打开控制台,否则将不会收到接听电话的提示。单击呼叫,提交另一个浏览器的 peer ID,然后接听电话。最终页面应如下所示:

两个浏览器都能连接通话

最后,要确保呼叫者可以终止呼叫。执行此操作的最佳方法是使用 close 来关闭连接,你可以在事件监听器中为挂断按钮执行此操作。

const hangUpBtn = document.querySelector('.hangup-btn');
hangUpBtn.addEventListener('click', function (){
    conn.close();
    showCallContent();
})

关闭连接后,要想显示正确的 HTML 内容,只需调用 showCallContent 函数即可。在 call 事件中,要想让远端浏览器也可以更新,可以在条件块中的 peer.on('call', function(stream){...} 事件监听器中添加另一个事件监听器。

conn.on('close', function (){
    showCallContent();
})

如果发起呼叫的人先单击“挂断”,那么两个浏览器都会被更新。

现在你已经拥有一部网络电话啦~

下一步

部署

部署此 app 最简单方法是 Glitch,用这种方法不用为 peer 服务器配置端口。

将这个作为 PWA

Samsung Internet 一直致力于 PWA(渐进式网页应用)的开发。下一阶段我们会添加 manifest.jsonserviceworker.js将其设置为 PWA。


Gotchas

  • 如果你在网上进行了一些调查,你可能以为 navigator.getUserMedia 可以替代 navigator.MediaDevices.getUserMedia,事实上不可以。我不建议使用前者,因为它需要回调以及约束作为参数。后者使用了 Promise,所以不用使用回调。
  • 因为我们使用 confirm 提示来询问用户是否要接听电话,因此确保调用的浏览器和标签页处于“活动状态”非常重要,不能将窗口最小化,要将标签页显示在屏幕上并将鼠标放在选项卡中的某个位置。建议大家使用 HTML 来创建自己的模式,就不会受到这些限制。
  • 当前我们对事物编码的方式意味着,当连接断开时,只有当发起呼叫者先按“挂断”时,两个浏览器才会更新。如果接听者先单击“挂断”,那么呼叫者也需要单击“挂断”才能查看正确的 HTML。
  • 目前在火狐浏览器上还无法使用 conn 变量上调用的 on('close') 事件,因此在火狐浏览器中,每个呼叫者都必须各自挂断。




原文作者:lola odelola
原文链接:https://medium.com/samsung-internet-dev/building-an-internet-connected-phone-with-peerjs-775bd6ffebec


推荐阅读
相关专栏
前端与跨平台
90 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。