Briswell Tech Blog

ブリスウェルのテックブログです

WebRTCを使ってビデオ通話アプリを作ってみました

皆さん、こんにちは! 最近は天気が暖かくなってきましたね。もうすぐ花見の時期になってきて楽しみですね。

皆さん、仕事をする時はビデオ通話のサービスをよく使っていますか?Google MeetsかZoomか、色んなビデオ通話のサービスがないと仕事に困りますね。 自分の方はこれらのサービスはどうすれば作れるかちょっと気になったので、調べて作ってみました。 新しい技術ではないですが、WebRTCという技術が存在します。 WebRTCを使ったら、誰でもビデオ通話アプリを作れます。

WebRTC(英語: Web Real-Time Communication)は、ウェブブラウザやモバイルアプリケーションにシンプルなAPI経由でリアルタイム通信(英語: real-time communication; RTC)を提供する自由かつオープンソースのプロジェクトである。ウェブページ内で直接ピア・ツー・ピア通信を行うことによって、プラグインのインストールやネイティブアプリのダウンロードを行わなくても、ウェブブラウザ間のボイスチャットビデオチャット、ファイル共有などを実装できるようになる。

wikipediaによる


アプリの概要

  • ビデオ通話の時、ビデオORオーディオの通信を自由に切れます。
  • 画面共有はできます。

使う技術:WebRTC + socket.io

処理のフロー

処理のフローはこの下のフローチャートを参考してください。

flowchart
処理のフローチャート

一部のサンプルのソースコード

クライアント側のソースコード

  • navigator.mediaDevices.getUserMediaで自分のPCのビデオとオーディオのデータストリームを取得する:
const localStream = ref(undefined);
...
localStream.value = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true,
  });
  • RTCPeerConnectionを作成して、オファーを送る:
// 後の処理に必要
const pcArr: { toSocketId: string; pc: RTCPeerConnection }[] = [];
...
const pc = new RTCPeerConnectionRTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.services.mozilla.com' },
    { urls: 'stun:stun.l.google.com:19302' },
  ],
});
pcArr.push({ toSocketId: user.socketId, pc });

// ビデオを表示するため
const remoteVideoData = reactive({
  socketId: user.socketId,
  mediaStream: undefined as MediaStream | undefined,
  userName: user.name,
});
if (!remoteVideo.value.find((video) => video.socketId === user.socketId)) {
  remoteVideo.value.push(remoteVideoData);
}

pc.ontrack = (event) => {
  if (
    remoteVideoData.mediaStream === undefined ||
    remoteVideoData.mediaStream.id === event.streams[0].id
  ) {
    // ウェブカムからのビデオ
    remoteVideoData.mediaStream = event.streams[0];
  } else {
    // 画面共有からのビデオ
    screenSharingActive.value = true;
    screenSharingStream.value = event.streams[0];
    screenSharingVideo.value!.srcObject = screenSharingStream.value;
  }
};

localStream.value!.getTracks().forEach((track) => {
  pc.addTrack(track, localStream.value!);
});

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

io.emit('room.offer', user.socketId, pc.localDescription, userName.value);

pc.onicecandidate = (event) => {
  if (event.candidate) {
    io.emit('room.candidate', user.socketId, event.candidate);
  }
};
  • オファー情報を保存する、アンサー情報を作って送る:
io.on('room.offer', async (socketId: string, offer: RTCSessionDescriptionInit, name: string) => {
  const pc = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.services.mozilla.com' },
      { urls: 'stun:stun.l.google.com:19302' },
    ],
  });

  localStream.value!.getTracks().forEach((track) => {
    pc.addTrack(track, localStream.value!);
  });

  const remoteVideoData = reactive({
    socketId,
    mediaStream: undefined as MediaStream | undefined,
    userName: name,
  });
  if (!remoteVideo.value.find((video) => video.socketId === socketId)) {
    remoteVideo.value.push(remoteVideoData);
  }

  pc.ontrack = (event) => {
    if (
      remoteVideoData.mediaStream === undefined ||
      remoteVideoData.mediaStream.id === event.streams[0].id
    ) {
      // ウェブカムからのビデオ
      remoteVideoData.mediaStream = event.streams[0];
    } else {
      // 画面共有からのビデオ
      screenSharingActive.value = true;
      screenSharingStream.value = event.streams[0];
      screenSharingVideo.value!.srcObject = screenSharingStream.value;
    }
  };

  await pc.setRemoteDescription(new RTCSessionDescription(offer));
  pcArr.push({ toSocketId: socketId, pc });

  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);

  io.emit('room.answer', socketId, pc.localDescription);

  pc.onicecandidate = (event) => {
    if (event.candidate) {
      io.emit('room.candidate', socketId, event.candidate);
    }
  };
});
  • オファーとアンサー共通の情報を保存する:
io.on('room.candidate', async (socketId: string, candidate: RTCIceCandidate) => {
  const pc = pcArr.find((pc) => pc.toSocketId === socketId)?.pc;
  if (pc) {
    await pc.addIceCandidate(new RTCIceCandidate(candidate));
  } else {
    alert('RTC not found!');
  }
});
  • アンサーを保存する:
io.on('room.answer', async (socketId: string, answer: RTCSessionDescription) => {
  const pc = pcArr.find((pc) => pc.toSocketId === socketId)?.pc;
  if (pc) {
    await pc.setRemoteDescription(new RTCSessionDescription(answer));
  } else {
    alert('RTC not found!');
  }
});
  • 画面共有:
const screenSharingVideo = ref(null);
const screenSharingStream = ref(undefined);
...
screenSharingStream.value = await navigator.mediaDevices.getDisplayMedia({
  video: { cursor: 'always' } as any,
  audio: false,
});
screenSharingVideo.value!.srcObject = screenSharingStream.value!;
screenSharingStream.value.getVideoTracks()[0].onended = () => {
  stopScreenSharing();
};

for (const pcData of pcArr) {
  screenSharingStream.value.getTracks().forEach((track) => {
    pcData.pc.addTrack(track, screenSharingStream.value!);
  });

  // 再びオファー送る
  const offer = await pcData.pc.createOffer();
  await pcData.pc.setLocalDescription(offer);
  io.emit('room.reoffer', pcData.toSocketId, offer);
}
  • 画面共有のオファーを保存する、新しいアンサーを作って送る:
io.on('room.reoffer', async (socketId: string, offer: RTCSessionDescription) => {
  const pc = pcArr.find((pc) => pc.toSocketId === socketId)?.pc;
  if (pc) {
    await pc.setRemoteDescription(new RTCSessionDescription(offer));
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    io.emit('room.answer', socketId, pc.localDescription);
  } else {
    alert('RTC not found!');
  }
});
  • ビデオORオーディオ通信を切る
const toogleVideo = () => {
  const videoTracks = localStream.value.getVideoTracks();
  videoTracks?.forEach((track) => {
    track.enabled = false;
  });
};

const toogleAudio = () => {
  const audioTracks = localStream.value.getAudioTracks();
  audioTracks?.forEach((track) => {
    track.enabled = false;
  });
};

バックエンド側のソースコード

socket.on('room.candidate', (userSocketId: string, candidate: RTCIceCandidate) => {
  socket.to(userSocketId).emit('room.candidate', socket.id, candidate);
});

socket.on('room.offer', (userSocketId: string, offer: RTCSessionDescription, name: string) => {
  socket.to(userSocketId).emit('room.offer', socket.id, offer, name);
});

// 画面共有で使う
socket.on('room.reoffer', (userSocketId: string, offer: RTCSessionDescription) => {
  socket.to(userSocketId).emit('room.reoffer', socket.id, offer);
});

socket.on('room.answer', (userSocketId: string, answer: RTCSessionDescription) => {
  socket.to(userSocketId).emit('room.answer', socket.id, answer);
});

今日の記事はここまでです。 何か質問があれば、遠慮なくコメント欄に投稿してください。