WebRTCを使ったビデオチャットサンプル

WebRTCを使うとブラウザ間で映像や音声をやり取りすることができます。あらかじめメールをやり取りする必要はなく、自社のサイト内に専用のビデオ会議用ページをつくることもできる。

ただし、設置するには「シグナリング・サーバー」と呼ばれる通信用のサーバーを自前で設置する必要がある。WebRTCは、ビデオ会議・チャット用のホームページを置いたサーバーと、シグナリング・サーバー、IPアドレスを調整するSTUN/TURNサーバーの3つのサーバーを介してブラウザ間の通信を構築し、構築後は直接ブラウザ同士でやり取りする。


WebRTCのイメージ
WebRTCイラスト

社内ネットワークなどでWebRTCを構築するのは比較的容易だが、顧客など不特定多数が利用するには、シグナリング・サーバーを外部に公開する必要がある。

シグナリング・サーバーを公開する場合、通常は独自ドメインをシグナリング・サーバー用に新たに取得し、当該シグナリング・サーバーと独自ドメインを紐づける必要がある。

結論としては、社内用はともかく、外部とのリモートワークならZoomやSkypeの方が(はるかに)簡単。以下のコードは参考までに。


  //シグナリングサーバへの接続                               
  var localStream=null;
  var peerConnections=[];
  //local:ローカル側,remote:リモート側
  var video=document.querySelector('#localVideo');
  var remoteVideos=[];
  //リモート側の映像の表示場所
  var remoteVideoContainer=document.querySelector('#remoteVideoContainer');
  var constraints={audio:true,video:{width:400,height:300}};
  
  const MAX_CONNECTIONS=8;
  
  //接続の有無
  var connection=true;
  
  var present_remote_id=null;
  
  //シグナリング・サーバへの接続(フロント側⇒シグナリング・サーバ)
  const port=8080;
  let socket=io.connect('https://example.luckyhawk.work:'+port+'/');
  let room='kuckyhawk';
  socket.on('connect',function(event){    
      socket.emit('enter',room);
      console.log('Socket.IO connected. ID:'+socket.id);
  });
  //socket.onによるメッセージ処理
  socket.on('message',function(message){
      let from=message.from;
      switch(message.type){
          case 'offer':{
              console.log('--offer from '+from);
              let offer=new window.RTCSessionDescription(message);
              setOffer(from,offer);
              break;
          }
          case 'answer':{
              console.log('--answer from '+from);
              let answer=new window.RTCSessionDescription(message);
              setAnswer(from,answer);
              break;
          }
          case 'candidate':{
              console.log('--candidate from '+from);
              let candidate=new window.RTCIceCandidate(message.ice);
              //addIceCandidate(from,candidate);
              console.log('add ICE cadidate.');
              if(!isConnected(from)){
                  console.warn('Not connected or already closed from id:'+from);
                  break;
              }
              let peerConnection=getConnection(from);
              if(!peerConnection){
                  console.error('PeerConnection does not exist.');
                  break;
              }
              peerConnection.addIceCandidate(candidate);
              break;
          }
            case 'call'://リモートからの接続要求が来た場合
                console.log('--call from '+from);
                if(connection==false) break;
                if(!localStream){
                    console.log('Not ready for connecting.');
                    break;
                }
                if(peerConnections.length>=MAX_CONNECTIONS){
                    console.warn('Too many connections.');
                    console.log('keys:'+Object.keys(peerConnections));
                    break;
                }
                if(isConnected(from)){
                    console.log('already connected.');
                    break;
                }
                createOffer(from);
                break;
            case 'bye':
                console.log('-- bye from '+from);
                stopConnection(from);
                break;
        }
    });
    
    socket.on('user disconnected',function(event){
        stopConnection(event.id);
    });
    
    //peer connection 関数
    function displayPeerConnections(){
        console.log('display peer connections list.');
        Object.keys(peerConnections).forEach(function(key){
            console.log(key,peerConnections[key]);
        });
    }
    
    function isConnected(id){
        if(!peerConnections[id]){
            console.log('not connected. ID:'+id);
            return false;
        }else{
            console.log('connected. ID:'+id);
            return true;
        }
    }
    
    function addConnection(id,peer){
        if(peerConnections[id]){
            console.log('peer already listed.');
        }else{
            peerConnections[id]=peer;
            if(peerConnections[id]){
                console.log('secceeded to add. ID:'+id);
            }else{
                console.log('failed to add. ID:'+id);
            }
        }    
    }
    
    function getConnection(id){
        if(isConnected(id)){
            return peerConnections[id];
        }else{
            return null;
        }
    }
    
    function stopConnection(id){
        detachRemoteVideo(id);
        if(isConnected(id)){
            let peer=getConnection(id);
            peer.close();
            delete peerConnections[id];
        }
    }
    
    function stopAllConnection(){
        for(let id in peerConnections){
            detachRemoteVideo(id);
            if(isConnected(id)){
                let peer=getConnection(id);
                peer.close();
                delete peerConnections[id];
            }
            stopConnection(id);
        }
    }
    
    //remote video 関数
    function attachRemoteVideo(id,stream){        
        let video=document.createElement('video');
        video.height=window.innerHeight-100;
        video.id=id;
        console.log('video id:'+id);
        present_remote_id=id;
        video.style.padding='5px';
        remoteVideoContainer.appendChild(video);
        remoteVideos[id]=video;
        video.srcObject=stream;
        video.play();
        console.log('attach remote video.');
    }
    
    //ウィンドウリサイズ時のリモート側ビデオのサイズ変更
    window.addEventListener('resize',function(){
        let video=document.getElementById(present_remote_id);
        video.width=window.innerWidth*0.7;
    },false)
    
    function detachRemoteVideo(id){
        if(remoteVideos[id]){
            remoteVideos[id].pause();
            delete remoteVideos[id];
            let video=document.getElementById(id);
            remoteVideoContainer.removeChild(video);
        }
    }
    
    function createOffer(id){
        console.log('create offer.');
        let peerConnection=createPeerConnection(id);
        addConnection(id,peerConnection);
        //offerの生成
        peerConnection.createOffer()
        .then(function(sessionDescription){
            console.log('type:'+sessionDescription.type);   
            return peerConnection.setLocalDescription(sessionDescription);
        }).then(function(){
            console.log('set local description.');
            //Vanilla ICE
            sendSdp(id,peerConnection.localDescription);
        }).catch(function(error){
            console.error(error);
        });
    }
    
    function setOffer(id,sessionDescription){
        console.log('set offer.');
        let peerConnection=createPeerConnection(id);
        console.log('peer who offered has been added to peerConnections.');
        addConnection(id,peerConnection);
        peerConnection.setRemoteDescription(sessionDescription)
        .then(function(){
            console.log('set remote description.');
            createAnswer(id);
        }).catch(function(error){
            console.error('(offer)set remote description error: ',error);
        });
    }
    
    function createAnswer(id){
        console.log('create answer.');
        let peerConnection=getConnection(id);
        if(!peerConnection) return;
        peerConnection.createAnswer()
        .then(function(sessionDescription){
            console.log('type:'+sessionDescription.type);
            return peerConnection.setLocalDescription(sessionDescription);
        }).then(function(){
            console.log('set local description.');
            //Trickle ICE
            sendSdp(id,peerConnection.localDescription);        
        }).catch(function(error){        
            console.log(error);
        });
    }
    
    function setAnswer(id,sessionDescription){
        console.log('set answer.');
        let peerConnection=getConnection(id);
        if(!peerConnection) return;    
        peerConnection.setRemoteDescription(sessionDescription)
        .then(function(){
            console.log('set remote description.');        
        }).catch(function(error){
            console.error('(answer)set remote description error: ',error);
        });
    }
    
    function createPeerConnection(id){
        //TURN,STUNサーバ
        let pcConfig={"iceServers":[{"url": "stun:stun.l.google.com:19302"}]};
        let peer=new RTCPeerConnection(pcConfig);
        //ストリームがある場合
        if('ontrack'in peer){
            console.log('--ontrack--');         
            peer.ontrack=function(event){
                //すでにリモート側の映像・音声ストリームを受信している場合            
                if(remoteVideos[id]){
                    console.log('already stream attached.');
                }else{
                    //eventのストリームに接続
                    let stream=event.streams[0];
                    attachRemoteVideo(id,stream);
                }
            };
            console.log('ontrack end.');        
        }else{
            console.log('--onaddstream--');        
            peer.onaddstream=function(event){            
                let stream=event.stream;
                attachRemoteVideo(id,stream);
            }
        }
        //ICE Candidateの送信
        peer.onicecandidate=function(event){
            console.log('on ICE Candidate.');
            if(event.candidate){//新しい候補者がいる場合          
                let msg={type:'candidate',ice:event.candidate};
                if(isConnected(id)){
                    msg.sendto=id;
                    socket.emit('message',msg);
                }else{
                    //Trickle ICE
                }
            }
        };
        peer.oniceconnectionstatechange=function(){
            switch(peer.iceConnectionState){
                case 'closed':
                case 'failed':
                stopConnection(id);
                break;
                case 'disconnected':
                break;
            }
        };
        peer.onremovestream=function(event){
            detachRemoteVideo(id);
        }
        //localStreamの追加
        if(localStream){
            localStream.getTracks().forEach(track=>peer.addTrack(track,localStream));
        }
        return peer;
    }
    
    function sendSdp(id,sessionDescription){
        console.log('send SDP to '+id);
        //シグナリング・サーバにSDPを送る
        let msg={type: sessionDescription.type,sdp: sessionDescription.sdp};
        msg.sendto=id;
        socket.emit('message',msg);
    }
    
    function hangUp(){
        socket.emit('message',{type: 'bye'});
        stopAllConnection();
    }
    
    $(function(){
        connect();
        $('#make-call').click(function(){
            console.log('call');
            if(peerConnections.length>=MAX_CONNECTIONS) return;
            connection=true;
            socket.emit('message',{type:'call'});        
        });
        $('#end-call').click(function(){
            hangUp();
            connection=false;
            console.log('disconnected');
        });
    });
    
    async function connect(){
        console.log('connect.');
        await navigator.mediaDevices.getUserMedia(constraints)
        .then(function(mediaStream){
            if(mediaStream) console.log('media stream.');
            localStream=mediaStream;        
            video.srcObject=mediaStream;
            video.onloadedmetadata=function(e){
                video.play();
            };
        }).catch(function(error){
            console.error('mediaDevices.getUserMedia error:',error);
        });
        if(localStream){
            console.log('local stream generated.');
        }else{
            console.log('local stream not generated.');
        }
        socket.emit('message',{type:'call'});
    }
    
    function showMyVideo(){
        console.log('show my video.');    
        navigator.mediaDevices.getUserMedia(constraints)
        .then(function(mediaStream){
            if(mediaStream) console.log('media stream.');
            localStream=mediaStream;        
            video.srcObject=mediaStream;
            video.onloadedmetadata=function(e){
                video.play();
            };
        }).catch(function(error){
            console.error('mediaDevices.getUserMedia error:',error);
        });
        if(localStream){
            console.log('local stream generated.');
        }else{
            console.log('local stream not generated.');
        }
      }; 

            


ビデオチャットサンプル

(独自ドメインの契約を更新してないので今は使えません)