使用 WebRTC 进行屏幕共享:利用 JavaScript 实现无缝流式传输

WebRTC 是一种允许Web应用程序与其他浏览器直接交换数据的技术,”无需 “中介。它使用多种协议协同工作来实现这一目标。

遗憾的是,WebRTC 无法自行发起连接,需要一个在客户端之间传输连接请求的服务器:称之为信号通道或信令服务器;一旦请求被接受,浏览器之间就可以共享信息了。

在本文中,我们将创建一个屏幕共享应用程序,使用 WebRTC 和 Websocket 服务器作为我们的信令服务器(自行构建)。

使用 WebRTC 进行屏幕共享:利用 JavaScript 实现无缝流式传输

创建初始项目

本项目将使用 Vite 启动一个 React + Typescript 项目:

npm create vite scrshr-app
使用 WebRTC 进行屏幕共享:利用 JavaScript 实现无缝流式传输

修改一下 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
  }
}

如下图:

使用 WebRTC 进行屏幕共享:利用 JavaScript 实现无缝流式传输

现在已经在屏幕上创建了用户界面组件,可以开始构建使用 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)
  }
  ...
}
使用 WebRTC 进行屏幕共享:利用 JavaScript 实现无缝流式传输

如上图所示,服务器打印出客户端已成功连接到 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 进行屏幕共享:利用 JavaScript 实现无缝流式传输

最后,需要监听来自 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/sendice/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)
  }
...
使用 WebRTC 进行屏幕共享:利用 JavaScript 实现无缝流式传输

作者:Filipe Melo
源自:dev.to

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/49056.html

(0)

相关推荐

  • WebRTC 屏幕分享深度解析

    前言 今天突然发现自己对 WebRTC 的屏幕分享的底层工作原理有一个误解,之前,我一直以为屏幕分享就是简单的采集桌面的画面,然后编码发送就行了。实时上并不是如此简单,本文就来为大…

    2022年10月26日

发表回复

登录后才能评论