Next.js で Web-RTC を利用したZoomのようなビデオ通話アプリを作成する

作成したアプリケーション

Next.js & Web-RTC を利用したビデオ通話アプリのサンプルです。

主な機能

  • ビデオ通話
  • 画面共有

利用している技術

  • Next.js (React16)
  • Typescript
  • Web-RTC

WebRTCとは?

「Web Real-Time Communication」の略称で、APIを経由して、ウェブブラウザやモバイルアプリでリアルタイム通信を実現しようと立ち上がったプロジェクトです。リアルタイム通信を標準化するために開発されました。

主に以下のような機能が実現できます。

  • カメラ・マイクといったデバイスへのアクセス
  • 仲介を必要とせずに(実際には完全なサーバーレスとは言い難い)ブラウザ・モバイル間でデータの交換ができる
  • キャプチャしたオーディオ/ビデオストリームの送受信が出来る

WebRTCで利用されているプロトコル

NAT

言わずと知れたパブリックIPアドレスとプライベートIPアドレスを変換するプロトコル。

パブリックIPアドレスの特定のポートを、特定のプライベートIPアドレスの特定のポートに固定的に対応づけたものをポートマッピングといいます。

STUN

パブリックIPアドレスを発見し、ピアとの直接接続を妨害するルーターの制限を特定するためのプロトコル。

クライアントがSTUNサーバーに問い合わせると、パブリックIPアドレス・ポートとルーターのNAT内部にアクセス可能かどうかを確認することができます。

TURN

「Symmetric NAT」 と呼ばれるルーターの制限を回避するためのプロトコル。具体的にはパブリックIPを持っているTURNサーバがクライアントにIPとPortを貸し出します。

SDP

送受信するメディア(動画・音声)の解像度、フォーマット、コーデック、暗号化などの、接続のマルチメディアコンテンツを記述するためのプロトコル。

P2P接続によって送受信されるデータを説明するためのメタデータと思っていいようです。

ICE

ブラウザ間でP2P接続を可能にするフレームワーク。これにより、P2P接続するための経路を決定する。

シグナリングサーバー

WebRTCではP2P接続を確定させるために、シグナリングサーバー(SDPやICE Candidateのやりとりを行う)が必要になります。シグナリングサーバーの実装にはWebSocketが使われることが多いようです。今回のサンプルアプリケーションでもシグナリングサーバーを利用しています。

メディアへのアクセス、RTC Peer Connectionの生成などはクライアント側のscriptに記述するので、シグナリングサーバーは簡単にクライアントからのデータを受信/配信するだけです。

docker/node/app/app.js

var io = socket(server, { cors: { origin: '*' } });

io.on('connection', socket => {
  console.log(socket.id);
  socket.emit('RECEIVE_CONNECTED', { id: socket.id });

  socket.on('SEND_MOUSE_EVENT', function(data) {
    console.log(data);
    console.log('room name:' + socket.roomname + ', id:' + socket.id);
    io.emit('RECEIVE_MOUSE_EVENT', data);
  });

  socket.on('SEND_ENTER', function(roomname) {
    socket.join(roomname);
    console.log('id=' + socket.id + ' enter room:' + roomname);
    socket.roomname = roomname;
    socket.broadcast
      .to(socket.roomname)
      .emit('RECEIVE_CALL', { id: socket.id });
  });

  socket.on('SEND_LEAVE', function(roomname) {
    console.log(
      new Date() +
        ' Peer disconnected. id:' +
        socket.id +
        ', room:' +
        socket.roomname
    );
    if (socket.roomname) {
      socket.broadcast
        .to(socket.roomname)
        .emit('RECEIVE_LEAVE', { id: socket.id });
      socket.leave(socket.roomname);
    }
  });

  socket.on('SEND_CALL', function() {
    console.log('call from:' + socket.id + ', room:' + socket.roomname);
    socket.broadcast
      .to(socket.roomname)
      .emit('RECEIVE_CALL', { id: socket.id });
  });

  socket.on('SEND_CANDIDATE', function(data) {
    console.log('candidate from:' + socket.id + ', room:' + socket.roomname);
    console.log('candidate target:' + data.target);
    if (data.target) {
      data.ice.id = socket.id;
      socket.to(data.target).emit('RECEIVE_CANDIDATE', data.ice);
    } else {
      console.log('candidate need target id');
    }
  });

  socket.on('SEND_SDP', function(data) {
    console.log(data);
    console.log(
      'sdp ' + data.sdp.type + ', from:' + socket.id + ', to:' + data.target
    );
    data.sdp.id = socket.id;
    if (data.target) {
      socket.to(data.target).emit('RECEIVE_SDP', data.sdp);
    } else {
      socket.broadcast.to(socket.roomname).emit('RECEIVE_SDP', data.sdp);
    }
  });

  socket.on('SEND_FULLSCREEN', function(data) {
    data.id = socket.id;
    console.log('full screen id: ' + data.id + ', room:' + socket.roomname);
    io.to(socket.roomname).emit('RECEIVE_FULLSCREEN', data);
  });
});

クライアントサイド

src/components/MultiVideoChat.tsx


・・・

    this.socket = io(process.env.WEBSOCKET_ADDRESS, { secure: true })
    this.socket.on('RECEIVE_CONNECTED', (data) => {
      console.log('socket.io connected. id=' + data.id)
      this.setState({ socketId: data.id })
      this.socket.emit('SEND_ENTER', this.state.room)
    })
    this.socket.on('RECEIVE_SDP', this.onReceiveSdp)
    this.socket.on('RECEIVE_CALL', this.onReceiveCall)
    this.socket.on('RECEIVE_CANDIDATE', this.onReceiveCandidate)
    this.socket.on('RECEIVE_LEAVE', this.onReceiveLeave)
    this.socket.on('RECEIVE_FULLSCREEN', this.onReceiveFullScreen)

・・・

  onReceiveSdp(sdp) {
    console.log('receive sdp :' + sdp.type)
    switch (sdp.type) {
      case 'offer':
        this.onOffer(sdp)
        break
      case 'answer':
        this.onAnswer(sdp)
        break
      default:
        console.log('unkown sdp...')
        break
    }
  }

・・・

  async onOffer(sdp) {
    console.log('receive sdp offer from:' + sdp.id)

    const peer = this.state.peers[sdp.id] || this.prepareNewConnection(sdp.id)
    if (this.senders[sdp.id]) {
      this.senders[sdp.id].forEach((sender) => {
        peer.removeTrack(sender)
      })
    }
    this.senders[sdp.id] = []
    const canvas = document.createElement('canvas')
    const stream =
      this.video.srcObject || (canvas as CanvasElement).captureStream(10)
    stream.getTracks().forEach((track) => {
      this.senders[sdp.id].push(peer.addTrack(track, stream))
    })
    console.log(peer)
    if (!this.state.peers[sdp.id]) {
      console.log('add peer :' + sdp.id)
      this.state.peers[sdp.id] = peer
      await this.setState({ peers: this.state.peers })
    }

    const offer = new RTCSessionDescription(sdp)
    await peer.setRemoteDescription(offer)
    this.makeAnswer(sdp.id)
  }

  async onAnswer(sdp) {
    console.log('receive sdp answer from:' + sdp.id)
    const peer = this.state.peers[sdp.id]
    if (!peer) return
    const answer = new RTCSessionDescription(sdp)
    await peer.setRemoteDescription(answer)
  }

  async onAddStream(id, stream) {
    console.log('onAddStream:' + id + ', stream.id:' + stream.id)
    const video = this.videos[id]
    console.log(id)
    console.log(video)
    try {
      if (video) {
        video.pause()
        video.srcObject = stream
        await video.play()
      }
    } catch (e) {
      console.log(e)
    }
  }

  onRemoveStream(id) {
    console.log('onRemoveStream:' + id)
  }

  onIceCandidate(id, icecandidate) {
    console.log('onIceCandidate:' + id)
    if (icecandidate) {
      // Trickle ICE
      this.sendIceCandidate(id, icecandidate)
    } else {
      // Vanilla ICE
      console.log('empty ice event')
    }
  }

  async makeOffer(id) {
    const peer = this.state.peers[id] || this.prepareNewConnection(id)
    console.log(peer)
    if (this.senders[id]) {
      this.senders[id].forEach((sender) => {
        peer.removeTrack(sender)
      })
    }
    this.senders[id] = []
    const canvas = document.createElement('canvas')
    const stream =
      this.video.srcObject || (canvas as CanvasElement).captureStream(10)
    stream.getTracks().forEach((track) => {
      this.senders[id].push(peer.addTrack(track, stream))
    })
    if (!this.state.peers[id]) {
      this.state.peers[id] = peer
      await this.setState({ peers: this.state.peers })
    }

    const offer = await peer.createOffer()
    await peer.setLocalDescription(offer)
    this.sendSdp(id, peer.localDescription)
  }

  async makeAnswer(id) {
    const peer = this.state.peers[id]
    const answer = await peer.createAnswer()
    await peer.setLocalDescription(answer)
    this.sendSdp(id, peer.localDescription)
  }

・・・

  sendSdp(id, sdp) {
    console.log('sending SDP:' + sdp.type + ', to:' + id)
    this.socket.emit('SEND_SDP', { target: id, sdp: sdp })
  }

  prepareNewConnection(id) {
    console.log('establish connection to:' + id)
    const config = { iceServers: [] }
    const peer = new RTCPeerConnection(config)
    peer.ontrack = (event) => {
      this.onAddStream(id, event.streams[0])
      const stream = event.streams[0]
      stream.onremovetrack = function (evt) {
        const track = evt.track
        if (track.kind === 'video') {
          // video除去時の処理
        } else if (track.kind === 'audio') {
          // audio除去時の処理
        }
      }
    }
    // peer.onremovestream = () => {
    //   this.onRemoveStream(id)
    // }
    peer.onicecandidate = (event) => {
      this.onIceCandidate(id, event.candidate)
    }
    peer.oniceconnectionstatechange = () => {
      if (peer.iceConnectionState === 'disconnected') {
        console.log('state disconnected to: ' + id)
        this.onDisconnect(id)
      }
    }
    peer.onnegotiationneeded = () => {
      console.log('onnegotiationneeded')
    }
    peer.onconnectionstatechange = () => {
      console.log('onconnectionstatechange: ' + peer.connectionState)
    }

    return peer
  }

  onDisconnect(id) {
    // 全体から切断
    const peer = this.state.peers[id]
    if (peer) {
      peer.close()
      delete this.state.peers[id]
      delete this.senders[id]
      this.setState({ peers: this.state.peers })
    }
  }

・・・

開発環境の構築

ソースコード

MIT ライセンスにてコードを公開していますのでご利用下さいませ。

https://github.com/isystk/nextjs-webrtc-sample

ディレクトリ構造

.
├── docker/ (Docker Apache)
│   ├── apache/
│   ├── node/
│   └── docker-compose.yml
├── src/ (クライアントサイド のソースコード)
│   ├── auth/
│   ├── common/
│   ├── components/
│   ├── pages/
│   ├── store/
│   ├── styles/
│   └── utilities/
└── test/

起動方法

# .env ファイルに ローカルのIPアドレスを設定
$ cp .env.example .env
# Dockerを起動
$ ./dc.sh start

# クライアントの起動
$ yarn
$ yarn dev

# ブラウザでアクセス
open https://xxx.xxx.xxx.xxx/

コメントを残す

入力エリアすべてが必須項目です。メールアドレスが公開されることはありません。

内容をご確認の上、送信してください。