WebRTCで多人数のビデオチャット
前回の記事では、WebRTCで1対1のビデオチャットを作ってみた。今回は、多対多でビデオチャットを実現したいと思う。今回も、とにかく動かすことを目標にする。
参考サイト
シグナリングサーバーを応用! 「WebRTCを使って複数人で話してみよう」
このサイトで、相手のIDをキーとしたpeerConectionの連想配列を作れば良さそうだということと、自分より先に入室しているクライアントの情報を知る必要があることがわかった。他にもチャットルームの実装について書かれているので、また参考にするかも。
環境
前回と一緒。
ソースコード
サーバー側
これも前回と一緒で、WebRTCを仕組みから実装までやってみるでGitHubにアップされているサーバのコードを使っている。
クライアント側
<!doctype html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title></title> <script type="text/javascript" src="http://code.jquery.com/jquery-latest.js"></script> <script type="text/javascript"> $(document).ready(function(){ // セレクター var myVideo = $('video#myVideo'); var messageBox = $('div#messageBox'); var index = 1; // websocket var websocket = new WebSocket('ws://localhost:8124/'); // peer var server = [{"url": "stun:stun.l.google.com:19302"}]; var peer = []; var streamIsAdded = false; var myStream = null; // ユーザIDをランダム生成 var my_uuid = UUID(); var userName = window.prompt("ユーザー名を入力してください", ""); function init(){ // 自分のカメラを利用する getUserMedia(); // websocketの実装 websocket.onopen = function() { onOpen(); }; websocket.onmessage = function(event) { onMessage(event); }; // ページを離れるとき $(window).bind("beforeunload", function() { // 退室情報を送信 websocket.send(JSON.stringify({ type: 'defect', uuid: my_uuid, userName : userName })); // websocketを切断 websocket.close(1000,"page unload"); }); } function getUserMedia(){ navigator.webkitGetUserMedia( { audio: true, video: true }, function(stream) { // 自分のカメラ映像を表示 myVideo.attr({ src : window.webkitURL.createObjectURL(stream) }); myVideo.animate({ width: "300px", height: "225px", }, 1000 ); myStream = stream; streamIsAdded = true; for(x in peer){ peer[x].addStream(myStream); messageBox.append('Add myStream ID: '+ x + '<br>'); } }, function(err) { console.log(err.name + ': ' + err.message); } ); } // peerを追加 function addPeer(id){ messageBox.append('addPeer<br>'); peer[id] = new webkitRTCPeerConnection({"iceServers": server}); if(myStream != null){ peer[id].addStream(myStream); messageBox.append('Add myStream ID: '+ id + '<br>'); } peer[id].onicecandidate = function(event) { onCandidate(event, id); }; peer[id].onaddstream = function(stream) { onAddStream(stream); }; } // 自分のcandidateデータの処理 function onCandidate(event, id){ if ( Object.keys(event.candidate).length > 0 ) { websocket.send(JSON.stringify({ type : 'candidate', candidate : event.candidate, from : my_uuid, to : id })); } } // 相手のカメラ映像が届いた時の処理 function onAddStream(stream){ $('video:eq('+ index +')').attr({ src : window.webkitURL.createObjectURL(stream.stream) }); $('video:eq('+ index +')').animate({ width: "300px", height: "225px", }, 1000 ); index++; } // websocket接続完了時の処理 function onOpen(){ // 入室情報を送信 websocket.send(JSON.stringify({ type: 'join', uuid: my_uuid, userName : userName })); } // websocketメッセージ受信イベントを処理 function onMessage(event){ // 受信したメッセージを復元 var data = JSON.parse(event.data); // pushされたメッセージを解釈し、要素を生成する if (data.type === 'join') { if(data.uuid == my_uuid){ messageBox.append(data.userName+ 'のID: '+ data.uuid + '<br>'); }else{ messageBox.append(data.userName + 'が入室しました <input type="button" id="'+ data.uuid +'" value="Send offer"><br>'); $('#' + data.uuid).click(function(){ if(streamIsAdded){ var to_uuid = $(this).attr('id'); var to_userName = data.userName; messageBox.append(to_userName + 'に接続申請をしました。<br>'); createOffer(to_uuid); }else{ messageBox.append('カメラを有効にしてください。<br>'); } }); addPeer(data.uuid); websocket.send(JSON.stringify({ type: 'responce', uuid: my_uuid, userName : userName })); } } else if (data.type === 'responce') { if(data.uuid != my_uuid){ addPeer(data.uuid); } } else if (data.type === 'defect') { messageBox.append(data.userName + 'が退室しました<br>'); $('#' + data.uuid).remove(); } else if (data.type === 'sdp') { if(data.to == my_uuid){ // 自分にSDPが送られてきた var sdp = new RTCSessionDescription(data.sdp); if(sdp.type == "offer"){ // offerをもらった messageBox.append(data.userName + 'から接続申請がありました。 <input type="button" id="'+ data.from +'" value="許可する"><br>'); $('input#' + data.from).click(function(){ if(streamIsAdded){ var from_userName = data.userName; messageBox.append(from_userName + 'に接続許可をしました。<br>'); var from_uuid = $(this).attr('id'); // リモートに相手のsdpをセット peer[from_uuid].setRemoteDescription(sdp, function() { // 送り主にanswerを返す createAnswer(from_uuid); }); }else{ messageBox.append('カメラを有効にしてください。<br>'); } }); }else if(sdp.type == "answer"){ // answerが返ってきた messageBox.append(data.userName + 'が接続を許可しました。<br>'); // リモートに相手のsdpをセット peer[data.from].setRemoteDescription(sdp, function() { messageBox.append('Session connection completed!!<br>'); }); } } } else if (data.type == 'candidate') { if(data.to == my_uuid){ var candidate = new RTCIceCandidate(data.candidate); peer[data.from].addIceCandidate(candidate); }else if(data.from == my_uuid){ var candidate = new RTCIceCandidate(data.candidate); peer[data.to].addIceCandidate(candidate); } } } function createOffer(to_uuid){ peer[to_uuid].createOffer(function(sdp) { // ローカルに自分のsdpをセット peer[to_uuid].setLocalDescription(sdp, function() { // セット完了したら、相手に自分のSDPを送る websocket.send(JSON.stringify({ type : 'sdp', sdp : sdp, from : my_uuid, to : to_uuid, userName : userName })); }); }); } function createAnswer(to_uuid){ peer[to_uuid].createAnswer(function(sdp) { // この引数のSDPは自分用! peer[to_uuid].setLocalDescription(sdp, function() { // セット完了したら、相手に自分のAnswerSDPを送る websocket.send(JSON.stringify({ type : 'sdp', sdp : sdp, from : my_uuid, to : to_uuid, userName : userName })); }); }); } function UUID() { var uuid = [ (((1+Math.random())*0x10000)|0).toString(16).substring(1), (((1+Math.random())*0x10000)|0).toString(16).substring(1), (((1+Math.random())*0x10000)|0).toString(16).substring(1), (((1+Math.random())*0x10000)|0).toString(16).substring(1), (((1+Math.random())*0x10000)|0).toString(16).substring(1), (((1+Math.random())*0x10000)|0).toString(16).substring(1), (((1+Math.random())*0x10000)|0).toString(16).substring(1), (((1+Math.random())*0x10000)|0).toString(16).substring(1) ].join(""); return uuid; } init(); }); </script> </head> <body> <table border="1" width="920" height="235"> <tr id="display"> <td> <video id="myVideo" width="60" height="45" autoplay="1" muted></video> <video width="60" height="45" autoplay="1"></video> <video width="60" height="45" autoplay="1"></video> <video width="60" height="45" autoplay="1"></video> <video width="60" height="45" autoplay="1"></video> <video width="60" height="45" autoplay="1"></video> </td> </tr> </table><br> <div id="messageBox"></div> </body> </html>
主な変更点
- 他クライアント入室情報の横にオファー送信ボタンを表示。オファー受信時にアンサー送信ボタンを表示。
- peerが連想配列になった。addPeer(id) で追加。
- 他クライアントが入室した(joinを受信した)時に、自分がいることを知らせる(responceを送信)
- id無しのvideoタグを追加した。
他にも余計な機能をつけてしまったので、コードがより一層ゴチャゴチャしている。
動かし方
ローカルでしか試していない。
① ターミナルでindex.jsのあるディレクトリに移動し、
node index.js
でWebSocketサーバを起動する。
② Chromeでクライアント側のHTMLファイルを開く。ユーザー名が聞かれるので入力する。
③ もう一つクライアントを開くと、先に開いているクライアントに入室情報と「Send offer」ボタンが表示される。カメラを有効にしていると、オファーを送ることができる。
④ オファーを受け取ったクライアントに、「許可する」ボタンが表示される。カメラを有効にして「許可する」ボタンを押すと接続が完了する。
③と④を繰り返せば、最大6人までチャットができる。ソースコードのvideoタグを増やせば、人数を増やせる。
感想
多人数のpeer接続の管理自体は思ったよりも簡単だった。しかし、videoタグに他クライアントのidをつけてonAddStream()時に区別しようとしたところ、なぜか上手く動かずにハマってしまった。代わりにvideoタグのセレクタに:eq(index)フィルタ追加し、相手との接続が完了するたびにindexを増やすという方法で解決した。また、candidateデータは相手にだけ送ればいいと思っていたが、自分のonIceCandidate()で発生したデータもaddIceCandidate()しないといけないということがわかった。