WebRTC 是一种允许Web应用程序与其他浏览器直接交换数据的技术,”无需 “中介。它使用多种协议协同工作来实现这一目标。
遗憾的是,WebRTC 无法自行发起连接,需要一个在客户端之间传输连接请求的服务器:称之为信号通道或信令服务器;一旦请求被接受,浏览器之间就可以共享信息了。
在本文中,我们将创建一个屏幕共享应用程序,使用 WebRTC 和 Websocket 服务器作为我们的信令服务器(自行构建)。
创建初始项目
本项目将使用 Vite 启动一个 React + Typescript 项目:
npm create vite scrshr-app
修改一下 App.tsx,添加一个带有输入框和两个按钮的表单。我们将使用该表单让用户创建/加入一个流 “房间”。
还可以删除它生成的一些文件,如 App.css 和 index.css。
// App.tsx
import { useAppState } from "./app.state"
function App() {
const {
inputRef,
onCreateRoom,
onJoinRoom
} = useAppState();
return (
<>
<form>
<input type='text' ref={inputRef}/>
<button type="button" onClick={onJoinRoom}>Join</button>
<button type="button" onClick={onCreateRoom}>Create</button>
</form>
<video width="320" height="240"/>
</>
)
}
export default App
// app.state.ts
import { useRef } from "react"
export function useAppState() {
const inputRef = useRef<HTMLInputElement>(null);
const onCreateRoom = () => {
console.log("Create room")
}
const onJoinRoom = () => {
console.log("Join room")
}
return {
onCreateRoom,
onJoinRoom,
inputRef
}
}
如下图:
现在已经在屏幕上创建了用户界面组件,可以开始构建使用 WebSocket 传输 webRTC 信息的逻辑。因此,在继续编写代码之前,需要安装 Socket.io:
npm i socket.io-client
处理 Websocket 事件(客户端)
创建一个名为 websocket.service.ts 的新文件。在该文件中,我们将实现与 websocket 连接相关的业务规则。在这里,我们将为事件添加监听器,并将事件触发到服务器。下面列出了我们要触发/监听的所有事件及其目的:
createRoom
:此事件将负责在后端创建一个“房间”,因此当新用户加入房间时我们可以监听它;joinRoom
:当用户点击“加入房间”按钮时,会触发该事件;onNewUserJoined
:此事件将告知房间所有者有用户加入,因此,房间所有者可以发送邀请;sendOffer
:一旦有新用户加入,就会触发此事件;我们会将 WebRTC Offer 对象发送给新用户;receiveOffer
:此事件将向刚加入的用户提供优惠,这样他们就可以触发下面解释的 sendAnswer 方法;sendAnswer
:此事件将由收到优惠的用户触发,允许他们使用其 webRFC Answer 对象进行回应;receiveAnswer
:加入的用户回应后就会触发此事件,让他们最终进行交流。
import { Socket, io } from "socket.io-client";
export class WebsocketService {
websocket: Socket;
constructor() {
this.websocket = io("http://localhost:3000");
}
createRoom(roomName: string) {
this.websocket.emit("room/create", {
roomName,
});
}
joinRoom(roomName: string) {
this.websocket.emit("room/join", {
roomName,
});
}
onNewUserJoined(callback: () => void) {
this.websocket.on("user/joined", callback);
}
sendOffer(roomName: string, offer: RTCSessionDescriptionInit) {
this.websocket.emit("offer/send", {
offer,
roomName,
});
}
receiveOffer(callback: (offer: RTCSessionDescriptionInit) => void) {
this.websocket.on("offer/receive", ({ offer }) => callback(offer));
}
sendAnswer(roomName: string, answer: RTCSessionDescriptionInit) {
this.websocket.emit("answer/send", {
answer,
roomName,
});
}
receiveAnswer(callback: (answer: RTCSessionDescriptionInit) => void) {
this.websocket.on("answer/receive", ({ answer }) => callback(answer));
}
创建信令服务器
现在开始创建信令服务器。创建一个名为 server 的新目录并运行以下命令:
npm init -y
这将生成 package.json
。由于我们在前端使用的是 typescript,在这里添加一些额外的步骤。
npm install typescript ts-node-dev -D
现在,可以运行以下命令启动类型脚本:
npx tsc --init
这里需要对 tsconfig.json 和 package.json 文件做了一些调整,如下所示:
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./build",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "./src/server.ts",
"scripts": {
"start": "node ./build/server.js",
"dev": "ts-node-dev .",
"build": "tsc "
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.2"
}
}
接下来安装 Express 和 Socket.io 库:
npm install express socket.io
npm install @types/express -D
设置 socket.io 以及 express :
//server.ts
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: "http://localhost:5173",
},
});
io.on("connection", () => {
console.log("connected");
});
httpServer.listen(3000);
最后,我们可以创建一个新文件,并在其中放置事件侦听器,以处理来自客户端的 WebSocket 请求(如前所述)。
//room.service.ts
import { Server } from "socket.io";
interface Room {
owner: string;
guest: string | null;
};
export class RoomService {
rooms = new Map<string, Room>();
constructor(private server: Server) {
}
initialize() {
this.server.on("connection", (socket) => {
console.log("user connected", socket.id)
})
}
}
并确保在 server.ts 文件上实例化此服务,如下所示:
//server.ts
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { RoomService } from "./room.service";
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: "http://localhost:5173",
},
});
const roomService = new RoomService(io);
roomService.initialize();
httpServer.listen(3000);
为了尝试这一切,需要在前端客户端上做一些调整。
// app.state.ts
...
const socketService = new WebsocketService();
export function useAppState() {
...
const onCreateRoom = () => {
if (!inputRef.current?.value) {
return;
}
socketService.createRoom(inputRef.current.value)
}
...
}
如上图所示,服务器打印出客户端已成功连接到 websocket。现在我们可以实现每个处理程序来处理每个事件,并在必要时发出事件。
处理 Websocket 事件(服务器)
现在继续在服务器上实现我们需要的所有逻辑,以便它能交换信息。
// room.service.ts
import { Server, Socket } from "socket.io";
interface Room {
owner: string;
guest: string | null;
}
export class RoomService {
rooms = new Map<string, Room>();
constructor(private server: Server) {}
initialize() {
this.server.on("connection", (socket) => {
this.handleOnCreateRoom(socket);
this.handleOnJoinRoom(socket);
this.handleOnSendOffer(socket);
this.handleOnSendAnswer(socket);
});
}
handleOnCreateRoom(socket: Socket) {
socket.on("room/create", (payload) => {
const { roomName } = payload;
this.rooms.set(roomName, {
owner: socket.id,
guest: null,
});
});
}
handleOnJoinRoom(socket: Socket) {
socket.on("room/join", (payload) => {
const {roomName} = payload;
const room = this.rooms.get(roomName);
if (!room) {
return;
}
room.guest = socket.id;
socket.to(room.owner).emit("user/joined");
})
}
handleOnSendOffer(socket: Socket) {
socket.on("offer/send", (payload) => {
const { roomName, offer } = payload;
const room = this.rooms.get(roomName);
if (!room || !room.guest) {
return;
}
socket.to(room.guest).emit("offer/receive", {
offer
})
})
}
handleOnSendAnswer(socket: Socket) {
socket.on("answer/send", (payload) => {
const { answer, roomName } = payload;
const room = this.rooms.get(roomName);
if (!room) {
return;
}
socket.to(room.owner).emit("answer/receive", {
answer
})
})
}
}
实现 WebRTC 服务
现在,开始设置 WebRTC 服务。首先,先设置 RTC 对等连接对象:
// webrtc.service.ts
export class WebRTCService {
private peerConnection: RTCPeerConnection;
constructor() {
const configuration: RTCConfiguration = {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
};
this.peerConnection = new RTCPeerConnection(configuration);
}
...
}
现在要实现以下方法:
makeOffer
:此方法将负责创建“offer”,其中将包含有关当前连接的所有信息,包括其媒体流;makeAnswer
:此方法与 make offer 类似,为一个 offer 创建 answer;setRemoteOffer
:一旦收到远程客户端的答复或提议,我们就会将其传递给setRemoteDescription
;setLocalOffer
:一旦我们做出提议或者答复,我们需要指定连接本地端的属性;getMediaStream
:使用 MediaDevices API,我们可以捕获显示器(或特定应用程序)的内容,并通过 webRTC 连接进行流式传输;onStream
:使用 ontrack 事件,我们可以接收来自 webRTC 流的流。
// webrtc.service.ts
export class WebRTCService {
private peerConnection: RTCPeerConnection;
constructor() {
const configuration: RTCConfiguration = {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
};
this.peerConnection = new RTCPeerConnection(configuration);
}
async makeOffer() {
const offer = await this.peerConnection.createOffer();
return offer;
}
async makeAnswer() {
const answer = await this.peerConnection.createAnswer();
return answer;
}
async setRemoteOffer(offer: RTCSessionDescriptionInit) {
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
}
async setLocalOffer(offer: RTCSessionDescriptionInit) {
await this.peerConnection.setLocalDescription(offer);
}
async getMediaStream() {
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
});
mediaStream.getTracks().forEach((track) => {
this.peerConnection.addTrack(track, mediaStream);
});
return mediaStream;
}
onStream(cb: (media: MediaStream) => void ) {
this.peerConnection.ontrack = function ({ streams: [stream] }) {
cb(stream);
};
}
}
回到 app.state.ts
文件,在其中实现逻辑。我们将从用户创建房间的角度出发。
// app.state.ts
...
const webRTCService = new WebRTCService();
export function useAppState() {
...
const onCreateRoom = () => {
...
const roomName = inputRef.current.value
socketService.createRoom(roomName)
socketService.onNewUserJoined(async () => {
const offer = await webRTCService.makeOffer();
webRTCService.setLocalOffer(offer);
socketService.sendOffer(roomName, offer);
})
socketService.receiveAnswer(async (answer) => {
await webRTCService.setRemoteOffer(answer);
})
}
...
}
如上所示,一旦用户创建了房间,就开始监听 websocket 事件。一旦另一个用户加入会话,我们就会通过 websocket 发送该消息。
最后,实现用户加入会话时的业务规则:
// app.state.ts
...
export function useAppState() {
...
const onJoinRoom = () => {
if (!inputRef.current?.value) {
return;
}
const roomName = inputRef.current.value
socketService.joinRoom(roomName);
socketService.receiveOffer(async (offer) => {
await webRTCService.setRemoteOffer(offer);
const answer = await webRTCService.makeAnswer();
await webRTCService.setLocalOffer(answer);
socketService.sendAnswer(roomName, answer);
})
}
...
}
连接已经建立,把流轨道添加到连接中。我们将在用户创建房间时,通过调用 getMediaStream 方法来实现这一点。我们还将使用该方法的返回值,这样就可以使用之前添加的视频标记在本地显示流媒体。
// app.state.ts
...
export function useAppState() {
...
const videoRef = useRef<HTMLVideoElement>(null);
const onCreateRoom = async () => {
if (!inputRef.current?.value) {
return;
}
const roomName = inputRef.current.value;
await startLocalStream();
socketService.createRoom(roomName)
...
}
...
async function startLocalStream () {
const mediaStream = await webRTCService.getMediaStream();
startStream(mediaStream, true);
}
function startStream(mediaStream: MediaStream, isLocal = false ) {
if (!videoRef.current) {
return;
}
videoRef.current.srcObject = mediaStream;
videoRef.current.muted = isLocal;
videoRef.current.play();
}
return {
...
videoRef
}
}
// App.tsx
...
function App() {
const {
...
videoRef
} = useAppState();
return (
<>
...
<video width="500" ref={videoRef}/>
</>
)
}
export default App
最后,需要监听来自 WebRTC 回调的流。
// app.state.ts
const onJoinRoom = () => {
...
webRTCService.onStream(startStream)
}
解决延迟问题
在启动 WebRTC 对等连接时,连接的每一端通常都会提出多个候选连接,直到双方就其中一个候选连接达成一致,并认为该连接最合适。然后,WebRTC 会使用候选连接的详细信息来启动连接。
您可能已经注意到,我们在连接时没有收到任何错误,但仍然无法在另一个客户端上看到流。出现这种情况是因为还没有实现共享 RTCIceCandidate 的逻辑。我们将创建更多的套接字事件,以便两个客户端最终能更快地交换信息和进行连接。
首先,在 WebRTC 事件中实现新方法,这样我们就能在它们发生变化时监听:
// webrtc.service.ts
export class WebRTCService {
...
onICECandidateChange(cb: (agr: RTCIceCandidate ) => void ) {
this.peerConnection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
if (event.candidate) {
cb(event.candidate);
}
}
}
async setICECandidate(candidate: RTCIceCandidate) {
await this.peerConnection.addIceCandidate(candidate);
}
}
现在要实现两个新的套接字事件:ice/send
和ice/receive
:
// websocket.service.ts
export class WebsocketService {
...
sendIceCandidate(roomName: string, iceCandidate: RTCIceCandidate) {
this.websocket.emit("ice/send", {
roomName,
iceCandidate
})
}
receiveIceCandidate(cb: (arg: RTCIceCandidate) => void ) {
this.websocket.on("ice/receive", ({iceCandidate}) => {
cb(iceCandidate);
})
}
}
在后台,由于所有者或访客都可以发送他们的 ICEC 候选人;我们可以添加一个非常简单的验证,将候选人发送给相应的客户端。
// room.service.ts
...
export class RoomService {
...
initialize() {
this.server.on("connection", (socket) => {
...
this.handleOnSendIceCandidate(socket);
});
}
...
handleOnSendIceCandidate(socket: Socket) {
socket.on("ice/send", (payload) => {
const { roomName, iceCandidate } = payload;
const room = this.rooms.get(roomName);
if (!room || !room.guest) {
return;
}
const isOwner = room.owner === socket.id;
const to = isOwner ? room.guest : room.owner;
socket.to(to).emit("ice/receive", {
iceCandidate,
});
});
}
}
最后,我们可以监听用户创建或加入时发生的事件:
// app.state.ts
...
const onCreateRoom = async () => {
...
setupIceCandidate(roomName)
}
const onJoinRoom = () => {
...
setupIceCandidate(roomName)
}
function setupIceCandidate (roomName: string) {
webRTCService.onICECandidateChange((candidate) => {
socketService.sendIceCandidate(roomName, candidate)
})
socketService.receiveIceCandidate(webRTCService.setICECandidate)
}
...
作者:Filipe Melo
源自:dev.to
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/49056.html