皆さん、こんにちは! 最近は天気が暖かくなってきましたね。もうすぐ花見の時期になってきて楽しみですね。
皆さん、仕事をする時はビデオ通話のサービスをよく使っていますか?Google MeetsかZoomか、色んなビデオ通話のサービスがないと仕事に困りますね。 自分の方はこれらのサービスはどうすれば作れるかちょっと気になったので、調べて作ってみました。 新しい技術ではないですが、WebRTCという技術が存在します。 WebRTCを使ったら、誰でもビデオ通話アプリを作れます。
WebRTC(英語: Web Real-Time Communication)は、ウェブブラウザやモバイルアプリケーションにシンプルなAPI経由でリアルタイム通信(英語: real-time communication; RTC)を提供する自由かつオープンソースのプロジェクトである。ウェブページ内で直接ピア・ツー・ピア通信を行うことによって、プラグインのインストールやネイティブアプリのダウンロードを行わなくても、ウェブブラウザ間のボイスチャット、ビデオチャット、ファイル共有などを実装できるようになる。
wikipediaによる
アプリの概要
- ビデオ通話の時、ビデオORオーディオの通信を自由に切れます。
- 画面共有はできます。
使う技術:WebRTC + socket.io
処理のフロー
処理のフローはこの下のフローチャートを参考してください。
- 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); });
今日の記事はここまでです。 何か質問があれば、遠慮なくコメント欄に投稿してください。