こんにちは!がねこまさしです。「WebRTCを使ってみよう」シリーズの最新話をお送りします。今回は、簡易的な放送局を作ってみましょう。
片方向配信の特徴
WebRTCを使った音声通話、ビデオチャットのサンプルには、双方向のものが多く見られます。ライブラリもそれを前提とした作りのモノが多いようです。なので今回は、片方向配信を実際に動かしてみましょう。
片方向配信には、双方向通信とは異なる特徴があります。
- 視聴側はカメラやマイクといった機器が不要なので、参加のハードルが下がる
- Peer-to-Peerでもフルメッシュ構造にはならないので、より多くの人が同時に利用できる
特に同時接続数はは双方向では4~5人が実用範囲なのに対し、片方向では10~30人程度に対して1つのPCから配信できます。ちょっとした仲間内のイベントや、社内イベントであれば、十分にカバーできるのではないでしょうか?(社内で動かせば、社内ネットワーク内で完結するので、セキュリティ部門に怒られることもないでしょうし…)
片方向配信をつなぐまで
今回の記事は、技術的には過去の記事「WebRTCを使って複数人で話してみよう」の内容でカバーされています。違いはPeer-to-Peer接続までの手順にあります。
片方向配信では、話す側(talk)と見る側(watch)が非対称です。どちらから通信を始めるかで、2つのシナリオに分かれます。
(A) 配信中に、新たに見る人(watch)が現れて、視聴を開始する
この場合、Peer-to-Peer確立までのシグナリングの流れは、次のようになります。
- 新しい視聴者(watchA)が、同じ部屋の中にいる全員に「映像ちょうだい(talk_request)」を送る
- 他の視聴者(watchB)は、単に無視する
- 配信側(talk)は新しくOffer SDPを生成し、watchAに対して送信する
- watchAはOffer SDPを受け取ったら、Answer SDPを生成してtalkに送り返す
- その後 ICE Candidate が複数交換され、最終的にPeer-to-Peerで片方向の映像ストリームが流れる
(2)視聴者(watch)が待機しているところに、話す側(talk)が配信を開始する
こちらの場合は、Peer-to-Peer確立までのシグナリングの流れは、次のようになります。
- 配信側(talk)が、同じ部屋の中にいる全員に「始めるよー(talk_ready)」を送る
- 各視聴者(watchA, watchB)は、「映像ちょうだい(talk_request)」を返す
- 配信側(talk)は各視聴者ごとに別々のOffer SDPを生成し、それぞれ送信する
- 各視聴者(watch)はOffer SDPを受け取ったら、Answer SDPを生成してtalkに送り返す
- その後 ICE Candidate が複数交換され、最終的にPeer-to-Peerで片方向の映像ストリームが流れる
先ほどのシーケンスの前に、開始の合図(talk_ready)を加えただけですね。
シグナリングサーバーのソースコード
シグナリングサーバーは、過去の記事「WebRTCを使って複数人で話してみよう」と同じものが使えます。ただしこの時はSocket.IO v0.9を使っていたので、今回はSocket.IO v1.0/v1.1の場合を掲載しておきます。サーバー開始の部分と、部屋名の保持の仕方が少し異なります。
var BROADCAST_ID = '_broadcast_'; // -- create the socket server on the port --- var srv = require('http').Server(); var io = require('socket.io')(srv); var port = 9001; srv.listen(port); console.log('signaling server started on port:' + port); // This callback function is called every time a socket // tries to connect to the server io.on('connection', function(socket) { // ---- multi room ---- socket.on('enter', function(roomname) { socket.join(roomname); console.log('id=' + socket.id + ' enter room=' + roomname); setRoomname(roomname); }); function setRoomname(room) { //// for v0.9 //socket.set('roomname', room); // for v1.0 socket.roomname = room; } function getRoomname() { var room = null; //// for v0.9 //socket.get('roomname', function(err, _room) { // room = _room; //}); // for v1.0 room = socket.roomname; return room; } function emitMessage(type, message) { // ----- multi room ---- var roomname = getRoomname(); if (roomname) { console.log('===== message broadcast to room -->' + roomname); socket.broadcast.to(roomname).emit(type, message); } else { console.log('===== message broadcast all'); socket.broadcast.emit(type, message); } } // When a user send a SDP message // broadcast to all users in the room socket.on('message', function(message) { message.from = socket.id; // get send target var target = message.sendto; if ( (target) && (target != BROADCAST_ID) ) { console.log('===== message emit to -->' + target); socket.to(target).emit('message', message); return; } // broadcast in room emitMessage('message', message); }); // When the user hangs up // broadcast bye signal to all users in the room socket.on('disconnect', function() { console.log('-- user disconnect: ' + socket.id); // --- emit ---- emitMessage('user disconnected', {id: socket.id}); // --- leave room -- var roomname = getRoomname(); if (roomname) { socket.leave(roomname); } }); });
クライアント:配信側(talk)のソースコード
以前の複数人でのケースとの違いを見ていきましょう。配信側では、相手側の映像を受け取る必要がないので、複数のvideoを処理する部分はごっそり削れます。また配信側の通信処理は、複数人で話す場合ととても近いです。
Peer-to-Peerの管理
以前と同じく、複数のPeer-to-Peer接続を管理するために便宜上のクラスと、関連する関数を作っておきます。
// -------------- multi connections -------------------- var MAX_CONNECTION_COUNT = 10; var connections = {}; // Connection hash function Connection() { // Connection Class var self = this; var id = ""; // socket.id of partner var peerconnection = null; // RTCPeerConnection instance //var established = false; // is Already Established //var iceReady = false; } function getConnection(id) { var con = null; con = connections[id]; return con; } function addConnection(id, connection) { connections[id] = connection; } function getConnectionCount() { var count = 0; for (var id in connections) { count++; } console.log('getConnectionCount=' + count); return count; } function isConnectPossible() { if (getConnectionCount() < MAX_CONNECTION_COUNT) return true; else return false; } function getConnectionIndex(id_to_lookup) { var index = 0; for (var id in connections) { if (id == id_to_lookup) { return index; } index++; } // not found return -1; } function deleteConnection(id) { delete connections[id]; } function stopAllConnections() { for (var id in connections) { var conn = connections[id]; conn.peerconnection.close(); conn.peerconnection = null; delete connections[id]; } } function stopConnection(id) { var conn = connections[id]; if(conn) { console.log('stop and delete connection with id=' + id); conn.peerconnection.close(); conn.peerconnection = null; delete connections[id]; } else { console.log('try to stop connection, but not found id=' + id); } } function isPeerStarted() { if (getConnectionCount() > 0) { return true; } else { return false; } }
※以前は自前の過剰なステータス管理フラグを使っていましたが、無用なのでやめました。
シグナリングサーバーへの接続とイベント処理
シグナリングサーバーに対して、socket.ioクライアントを使って接続しておきます。また、接続時(会議室への入室)、切断時、メッセージ受信時のイベントハンドラを設定します。
// ---- socket ------ // create socket var socketReady = false; var port = 9001; var socket = io.connect('http://signaling.yourdomain:' + port + '/'); // サーバのURLに変更 // socket: channel connected socket.on('connect', onOpened) .on('message', onMessage) .on('user disconnected', onUserDisconnect); function onOpened(evt) { console.log('socket opened.'); socketReady = true; var roomname = getRoomName(); // 会議室名を取得する socket.emit('enter', roomname); console.log('enter to ' + roomname); } // socket: accept connection request function onMessage(evt) { var id = evt.from; var target = evt.sendto; var conn = getConnection(id); if (evt.type === 'talk_request') { if (! isLocalStreamStarted()) { console.warn('local stream not started. ignore request'); return; } console.log("receive request, start offer."); sendOffer(id); return; } else if (evt.type === 'answer' && isPeerStarted()) { // ** console.log('Received answer, settinng answer SDP'); onAnswer(evt); } else if (evt.type === 'candidate' && isPeerStarted()) { // ** console.log('Received ICE candidate...'); onCandidate(evt); } else if (evt.type === 'bye') { // ** console.log("got bye."); stopConnection(id); } } function onUserDisconnect(evt) { console.log("disconnected"); if (evt) { stopConnection(evt.id); } } function getRoomName() { // たとえば、 URLに ?roomname とする var url = document.location.href; var args = url.split('?'); if (args.length > 1) { var room = args[1]; if (room != "") { return room; } } return "_defaultroom"; }
応答するメッセージは、talk_request, answer, candidate です。talk_ready, offerは来ないはずなので、処理はしていません。
映像、音声の取得開始
// ---------------------- video handling ----------------------- // start local video function startVideo() { navigator.webkitGetUserMedia({video: true, audio: true}, function (stream) { // success localStream = stream; localVideo.src = window.webkitURL.createObjectURL(stream); localVideo.play(); localVideo.volume = 0; // auto start tellReady(); }, function (error) { // error console.error('An error occurred:'); console.error(error); return; } ); }
特に変わったところはありませんが、映像取得後に tellReady()を呼び、その中で「準備できたよ(talk_ready)」と通知しています。
配信要求への応答
視聴側(watch)から、配信要求(talk_request)があった場合、次の処理が呼び出されます。
var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':false, 'OfferToReceiveVideo':false }}; function sendOffer(id) { var conn = getConnection(id); if (!conn) { conn = prepareNewConnection(id); } conn.peerconnection.createOffer(function (sessionDescription) { // in case of success conn.peerconnection.setLocalDescription(sessionDescription); sessionDescription.sendto = id; sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Offer failed"); }, mediaConstraints); } // ---------------------- connection handling ----------------------- function prepareNewConnection(id) { var pc_config = {"iceServers":[]}; var peer = null; try { peer = new webkitRTCPeerConnection(pc_config); } catch (e) { console.log("Failed to create PeerConnection, exception: " + e.message); } var conn = new Connection(); conn.id = id; conn.peerconnection = peer; peer.id = id; addConnection(id, conn); // send any ice candidates to the other peer peer.onicecandidate = function (evt) { if (evt.candidate) { console.log(evt.candidate); sendCandidate({type: "candidate", sendto: conn.id, sdpMLineIndex: evt.candidate.sdpMLineIndex, sdpMid: evt.candidate.sdpMid, candidate: evt.candidate.candidate}); } else { console.log("ICE event. phase=" + evt.eventPhase); } }; console.log('Adding local stream...'); peer.addStream(localStream); return conn; }
複数人の双方向のケースと違い、映像や音声を受け取る必要がないので、mediaConstraintsの内容がどちらも受信不要(false)にしています。実際に通信を行うオブジェクトを用意するのは、prepareNewConnection()の中で行っています。
- RTCPeerConnectionを生成
- ICE candidate生成時のイベントハンドラを設定
配信開始時の通知
反対に、配信側(talk)から新たに配信開始を通知する処理はこちらです。単にsocket越しに部屋内にメッセージを投げるだけですね。
function tellReady() { if (! isLocalStreamStarted()) { alert("Local stream not running yet. Please [Start Video] or [Start Screen]."); return; } if (! socketReady) { alert("Socket is not connected to server. Please reload and try again."); return; } // call others, in same room console.log("tell ready to others in same room, befeore offer"); socket.json.send({type: "talk_ready"}); }
SDP、ICEのやり取り
SDPやICE Candidateも、socket越しに相手(1人)に送るだけです。
function sendSDP(sdp) { var text = JSON.stringify(sdp); console.log("---sending sdp text ---"); console.log(text); // send via socket socket.json.send(sdp); } function sendCandidate(candidate) { var text = JSON.stringify(candidate); console.log("---sending candidate text ---"); console.log(text); // send via socket socket.json.send(candidate); }
Answerや、Candidateを受け取った場合は、PeerConnectionに覚えさせます。
function setAnswer(evt) { var id = evt.from; var conn = getConnection(id); if (! conn) { console.error('peerConnection not exist!'); return } conn.peerconnection.setRemoteDescription(new RTCSessionDescription(evt)); } function onCandidate(evt) { var id = evt.from; var conn = getConnection(id); if (! conn) { console.error('peerConnection not exist!'); return; } var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate}); console.log("Received Candidate...") console.log(candidate); conn.peerconnection.addIceCandidate(candidate); }
クライアント:視聴側(watch)のソースコード
視聴側は自分のユーザーメディアは取得しません。映像も1つだけ受け取るので処理はシンプルです。
Peer-to-Peerの管理
Peer-to-Peerは1つだけなので、本来便宜上のクラス、関数は不要です。とは言えtalk側と共通にするために(あるいは複数人からの変更を減らすため)、同じコードにしておきました。
// -------------- multi connections -------------------- var MAX_CONNECTION_COUNT = 1; var connections = {}; // Connection hash function Connection() { // Connection Class var self = this; var id = ""; // socket.id of partner var peerconnection = null; // RTCPeerConnection instance } function getConnection(id) { var con = null; con = connections[id]; return con; } function addConnection(id, connection) { connections[id] = connection; } function getConnectionCount() { var count = 0; for (var id in connections) { count++; } console.log('getConnectionCount=' + count); return count; } function isConnectPossible() { if (getConnectionCount() < MAX_CONNECTION_COUNT) return true; else return false; } function getConnectionIndex(id_to_lookup) { var index = 0; for (var id in connections) { if (id == id_to_lookup) { return index; } index++; } // not found return -1; } function deleteConnection(id) { delete connections[id]; } function stopAllConnections() { for (var id in connections) { var conn = connections[id]; conn.peerconnection.close(); conn.peerconnection = null; delete connections[id]; } } function stopConnection(id) { var conn = connections[id]; if(conn) { console.log('stop and delete connection with id=' + id); conn.peerconnection.close(); conn.peerconnection = null; delete connections[id]; } else { console.log('try to stop connection, but not found id=' + id); } } function isPeerStarted() { if (getConnectionCount() > 0) { return true; } else { return false; } }
※同じと言いましたが、1点違いがありました。 MAX_CONNECTION_COUNT = 1 にしています。
シグナリングサーバーへの接続とイベント処理
シグナリングサーバーへの接続はtalkと同じです。処理するメッセージの種類が異なります。
// ---- socket ------ // create socket var socketReady = false; var port = 9001; var socket = io.connect('http://signaling.yourdomain:' + port + '/'); // socket: channel connected socket.on('connect', onOpened) .on('message', onMessage) .on('user disconnected', onUserDisconnect); function onOpened(evt) { console.log('socket opened.'); socketReady = true; var roomname = getRoomName(); // 会議室名を取得する socket.emit('enter', roomname); console.log('enter to ' + roomname); } // socket: accept connection request function onMessage(evt) { var id = evt.from; var target = evt.sendto; var conn = getConnection(id); if (evt.type === 'talk_ready') { if (conn) { return; // already connected } if (isConnectPossible()) { socket.json.send({type: "talk_request", sendto: id }); } else { console.warn('max connections. so ignore call'); } return; } else if (evt.type === 'offer') { console.log("Received offer, set offer, sending answer....") onOffer(evt); } else if (evt.type === 'candidate' && isPeerStarted()) { // ** console.log('Received ICE candidate...'); onCandidate(evt); } else if (evt.type === 'end_talk') { // ** console.log("got talker bye."); detachVideo(id); // force detach video stopConnection(id); } } function onUserDisconnect(evt) { console.log("disconnected"); if (evt) { detachVideo(evt.id); // force detach video stopConnection(evt.id); } } function getRoomName() { // たとえば、 URLに ?roomname とする var url = document.location.href; var args = url.split('?'); if (args.length > 1) { var room = args[1]; if (room != "") { return room; } } return "_defaultroom"; }
応答するメッセージは talk_ready, offer, candidate です。talk_requestやanswerには反応しません。
配信の要求
配信の依頼は、socket.ioで会議室内全員に(相手を特定せずに)投げます。
function sendRequest() { if (! socketReady) { alert("Socket is not connected to server. Please reload and try again."); return; } // call others, in same room console.log("send request in same room, ask for offer"); socket.json.send({type: "talk_request"}); }
SDP受信時の処理、ストリーム受信時の処理
talkからOffer SDPを受け取ったら、Peer-to-Peer通信の準備をします。PeerConnectionを生成し、Offerを覚えて、Answerを返します。
var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':true, 'OfferToReceiveVideo':true }}; function setOffer(evt) { var id = evt.from; var conn = getConnection(id); if (! conn) { conn = prepareNewConnection(id); conn.peerconnection.setRemoteDescription(new RTCSessionDescription(evt)); } else { console.error('peerConnection alreay exist!'); } } function sendAnswer(evt) { console.log('sending Answer. Creating remote session description...' ); var id = evt.from; var conn = getConnection(id); if (! conn) { console.error('peerConnection not exist!'); return; } conn.peerconnection.createAnswer(function (sessionDescription) { // in case of success conn.peerconnection.setLocalDescription(sessionDescription); sessionDescription.sendto = id; sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Answer failed"); }, mediaConstraints); }
PeerConnectionを準備しているのは、prepareNewConnection()の中です。talkとほとんど同じですが、一部異なる部分があります。
// ---------------------- connection handling ----------------------- function prepareNewConnection(id) { var pc_config = {"iceServers":[]}; var peer = null; try { peer = new webkitRTCPeerConnection(pc_config); } catch (e) { console.log("Failed to create PeerConnection, exception: " + e.message); } var conn = new Connection(); conn.id = id; conn.peerconnection = peer; peer.id = id; addConnection(id, conn); // send any ice candidates to the other peer peer.onicecandidate = function (evt) { if (evt.candidate) { console.log(evt.candidate); sendCandidate({type: "candidate", sendto: conn.id, sdpMLineIndex: evt.candidate.sdpMLineIndex, sdpMid: evt.candidate.sdpMid, candidate: evt.candidate.candidate}); } else { console.log("on ice event. phase=" + evt.eventPhase); } }; //console.log('Adding local stream...'); //peer.addStream(localStream); // 自分の映像ストリームは無し // 相手のストリームのハンドラを追加 peer.addEventListener("addstream", onRemoteStreamAdded, false); peer.addEventListener("removestream", onRemoteStreamRemoved, false) // when remote adds a stream, hand it on to the local video element function onRemoteStreamAdded(event) { console.log("Added remote stream"); remoteVideo.src = window.webkitURL.createObjectURL(event.stream); } // when remote removes a stream, remove it from the local video element function onRemoteStreamRemoved(event) { console.log("Remove remote stream"); detachVideo(this.id); } return conn; }
自分のストリーム(localStream)がない代わりに、相手のストリーム(RemoteStream)のハンドラを用意しています。
配信開始通知を受けた場合
talk側から talk_ready を受け取った場合、まだ接続が確立していなければ、talk_requestを返します。その後処理は配信の要求と同様に進みます。
function onMessage(evt) { var id = evt.from; var target = evt.sendto; var conn = getConnection(id); if (evt.type === 'talk_ready') { if (conn) { return; // already connected } if (isConnectPossible()) { socket.json.send({type: "talk_request", sendto: id }); } else { console.warn('max connections. so ignore call'); } return; }
動かしてみよう
配信前
配信開始後
このように、複数のブラウザに配信を行うことができます。ぜひ社内などで試してみてください。シグナリングサーバーを流用してテキストチャットなどを付けると、より楽しく利用できますよ!
※今回のソースには含まれていませんが、TURNサーバーを用意すれば、Firewall/NATの内側から外側に配信することも可能です。
クライアント側のソースコード(全体)
配信側と視聴側の主な違い
配信側(talk)と視聴側(watch)の仕組みは似通っていますが、違う部分もあります。改めて違う部分を整理しておきます。
(1) 配信側だけがユーザーメディアを取得する。PeerConnectionを生成したときに、そのストリームを追加する。
- peerconnection.addStream(localStream)
(2) 受信側だけが、相手のメディアストリームの接続、除去イベントを処理する。
- peer.addEventListener(“addstream”, onRemoteStreamAdded, false);
- peer.addEventListener(“removestream”, onRemoteStreamRemoved, false);
(3) 配信側と受信側は異なるメッセージに応答する。
- 配信側だけ:talk_request, answer
- 受信側だけ:talk_ready, offer
配信側(talk.html)
<!DOCTYPE html> <html> <head> <title>Broadcast Talk</title> <meta http-equiv="content-type" content="text/html; charset=UTF-8"> </head> <body> <button type="button" onclick="startVideo();">Start video</button> <button type="button" onclick="stopVideo();">Stop video</button> <button type="button" onclick="tellReady();">On Air</button> <br /> <div style="position: relative;"> <video id="local-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video> </div> <!---- socket ※自分のシグナリングサーバーに合わせて変更してください------> <script src="http://signaling.yourdomain:9001/socket.io/socket.io.js"></script> <script> var localVideo = document.getElementById('local-video'); var localStream = null; var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':false, 'OfferToReceiveVideo':false }}; function isLocalStreamStarted() { if (localStream) { return true; } else { return false; } } // -------------- multi connections -------------------- var MAX_CONNECTION_COUNT = 10; var connections = {}; // Connection hash function Connection() { // Connection Class var self = this; var id = ""; // socket.id of partner var peerconnection = null; // RTCPeerConnection instance } function getConnection(id) { var con = null; con = connections[id]; return con; } function addConnection(id, connection) { connections[id] = connection; } function getConnectionCount() { var count = 0; for (var id in connections) { count++; } console.log('getConnectionCount=' + count); return count; } function isConnectPossible() { if (getConnectionCount() < MAX_CONNECTION_COUNT) return true; else return false; } function getConnectionIndex(id_to_lookup) { var index = 0; for (var id in connections) { if (id == id_to_lookup) { return index; } index++; } // not found return -1; } function deleteConnection(id) { delete connections[id]; } function stopAllConnections() { for (var id in connections) { var conn = connections[id]; conn.peerconnection.close(); conn.peerconnection = null; delete connections[id]; } } function stopConnection(id) { var conn = connections[id]; if(conn) { console.log('stop and delete connection with id=' + id); conn.peerconnection.close(); conn.peerconnection = null; delete connections[id]; } else { console.log('try to stop connection, but not found id=' + id); } } function isPeerStarted() { if (getConnectionCount() > 0) { return true; } else { return false; } } // ---- socket ------ // create socket var socketReady = false; var port = 9001; var socket = io.connect('http://signaling.yourdomain:' + port + '/'); // ※自分のシグナリングサーバーに合わせて変更してください // socket: channel connected socket.on('connect', onOpened) .on('message', onMessage) .on('user disconnected', onUserDisconnect); function onOpened(evt) { console.log('socket opened.'); socketReady = true; var roomname = getRoomName(); // 会議室名を取得する socket.emit('enter', roomname); console.log('enter to ' + roomname); } // socket: accept connection request function onMessage(evt) { var id = evt.from; var target = evt.sendto; var conn = getConnection(id); if (evt.type === 'talk_request') { if (! isLocalStreamStarted()) { console.warn('local stream not started. ignore request'); return; } console.log("receive request, start offer."); sendOffer(id); return; } else if (evt.type === 'answer' && isPeerStarted()) { console.log('Received answer, settinng answer SDP'); onAnswer(evt); } else if (evt.type === 'candidate' && isPeerStarted()) { console.log('Received ICE candidate...'); onCandidate(evt); } else if (evt.type === 'bye') { console.log("got bye."); stopConnection(id); } } function onUserDisconnect(evt) { console.log("disconnected"); if (evt) { stopConnection(evt.id); } } function getRoomName() { // たとえば、 URLに ?roomname とする var url = document.location.href; var args = url.split('?'); if (args.length > 1) { var room = args[1]; if (room != "") { return room; } } return "_defaultroom"; } function onAnswer(evt) { console.log("Received Answer...") console.log(evt); setAnswer(evt); } function onCandidate(evt) { var id = evt.from; var conn = getConnection(id); if (! conn) { console.error('peerConnection not exist!'); return; } var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate}); console.log("Received Candidate...") console.log(candidate); conn.peerconnection.addIceCandidate(candidate); } function sendSDP(sdp) { var text = JSON.stringify(sdp); console.log("---sending sdp text ---"); console.log(text); // send via socket socket.json.send(sdp); } function sendCandidate(candidate) { var text = JSON.stringify(candidate); console.log("---sending candidate text ---"); console.log(text); // send via socket socket.json.send(candidate); } // ---------------------- video handling ----------------------- // start local video function startVideo() { navigator.webkitGetUserMedia({video: true, audio: true}, function (stream) { // success localStream = stream; localVideo.src = window.webkitURL.createObjectURL(stream); localVideo.play(); localVideo.volume = 0; // auto start tellReady(); }, function (error) { // error console.error('An error occurred:'); console.error(error); return; } ); } // stop local video function stopVideo() { hangUp(); localVideo.src = ""; localStream.stop(); localStream = null; } // ---------------------- connection handling ----------------------- function prepareNewConnection(id) { var pc_config = {"iceServers":[]}; var peer = null; try { peer = new webkitRTCPeerConnection(pc_config); } catch (e) { console.log("Failed to create PeerConnection, exception: " + e.message); } var conn = new Connection(); conn.id = id; conn.peerconnection = peer; peer.id = id; addConnection(id, conn); // send any ice candidates to the other peer peer.onicecandidate = function (evt) { if (evt.candidate) { console.log(evt.candidate); sendCandidate({type: "candidate", sendto: conn.id, sdpMLineIndex: evt.candidate.sdpMLineIndex, sdpMid: evt.candidate.sdpMid, candidate: evt.candidate.candidate}); } else { console.log("ICE event. phase=" + evt.eventPhase); //conn.established = true; } }; console.log('Adding local stream...'); peer.addStream(localStream); return conn; } function sendOffer(id) { var conn = getConnection(id); if (!conn) { conn = prepareNewConnection(id); } conn.peerconnection.createOffer(function (sessionDescription) { // in case of success conn.peerconnection.setLocalDescription(sessionDescription); sessionDescription.sendto = id; sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Offer failed"); }, mediaConstraints); } function setAnswer(evt) { var id = evt.from; var conn = getConnection(id); if (! conn) { console.error('peerConnection not exist!'); return } conn.peerconnection.setRemoteDescription(new RTCSessionDescription(evt)); } // -------- handling user UI event ----- function tellReady() { if (! isLocalStreamStarted()) { alert("Local stream not running yet. Please [Start Video] or [Start Screen]."); return; } if (! socketReady) { alert("Socket is not connected to server. Please reload and try again."); return; } // call others, in same room console.log("tell ready to others in same room, befeore offer"); socket.json.send({type: "talk_ready"}); } // stop the connection upon user request function hangUp() { console.log("Hang up."); socket.json.send({type: "end_talk"}); stopAllConnections(); } </script> </body> </html>
視聴側(watch.html)
<!DOCTYPE html> <html> <head> <title>broadcast watch</title> <meta http-equiv="content-type" content="text/html; charset=UTF-8"> </head> <body> <button type="button" onclick="sendRequest();">Request</button> <button type="button" onclick="hangUp();">Hang Up</button> <br /> <div style="position: relative;"> <video id="remote-video" autoplay style="width: 320px; height: 240px; border: 1px solid black;"></video> </div> <!---- socket ※自分のシグナリングサーバーに合わせて変更してください ------> <script src="http://signaling.yourdomain:9001/socket.io/socket.io.js"></script> <script> //var localVideo = document.getElementById('local-video'); var remoteVideo = document.getElementById('remote-video'); //var localStream = null; var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':true, 'OfferToReceiveVideo':true }}; function detachVideo(id) { if (id) { var conn = getConnection(id); if (conn) { remoteVideo.pause(); remoteVideo.src = ""; } } else { // force detach remoteVideo.pause(); remoteVideo.src = ""; } } function resizeRemoteVideo() { console.log('--resize--'); var top_margin = 40; var left_margin = 20; var video_margin = 10; var new_width = window.innerWidth - left_margin - video_margin; var new_height = window.innerHeight - top_margin - video_margin; remoteVideo.style.width = new_width + 'px'; remoteVideo.style.height = new_height + 'px'; remoteVideo.style.top = top_margin + 'px'; remoteVideo.style.left = left_margin + 'px'; } document.body.onresize = resizeRemoteVideo; resizeRemoteVideo(); // -------------- multi connections -------------------- var MAX_CONNECTION_COUNT = 1; var connections = {}; // Connection hash function Connection() { // Connection Class var self = this; var id = ""; // socket.id of partner var peerconnection = null; // RTCPeerConnection instance } function getConnection(id) { var con = null; con = connections[id]; return con; } function addConnection(id, connection) { connections[id] = connection; } function getConnectionCount() { var count = 0; for (var id in connections) { count++; } console.log('getConnectionCount=' + count); return count; } function isConnectPossible() { if (getConnectionCount() < MAX_CONNECTION_COUNT) return true; else return false; } function getConnectionIndex(id_to_lookup) { var index = 0; for (var id in connections) { if (id == id_to_lookup) { return index; } index++; } // not found return -1; } function deleteConnection(id) { delete connections[id]; } function stopAllConnections() { for (var id in connections) { var conn = connections[id]; conn.peerconnection.close(); conn.peerconnection = null; delete connections[id]; } } function stopConnection(id) { var conn = connections[id]; if(conn) { console.log('stop and delete connection with id=' + id); conn.peerconnection.close(); conn.peerconnection = null; delete connections[id]; } else { console.log('try to stop connection, but not found id=' + id); } } function isPeerStarted() { if (getConnectionCount() > 0) { return true; } else { return false; } } // ---- socket ------ // create socket var socketReady = false; var port = 9001; var socket = io.connect('http://signaling.yourdomain:' + port + '/'); // ※自分のシグナリングサーバーに合わせて変更してください // socket: channel connected socket.on('connect', onOpened) .on('message', onMessage) .on('user disconnected', onUserDisconnect); function onOpened(evt) { console.log('socket opened.'); socketReady = true; var roomname = getRoomName(); // 会議室名を取得する socket.emit('enter', roomname); console.log('enter to ' + roomname); } // socket: accept connection request function onMessage(evt) { var id = evt.from; var target = evt.sendto; var conn = getConnection(id); console.log('onMessage() evt.type='+ evt.type); if (evt.type === 'talk_ready') { if (conn) { return; // already connected } if (isConnectPossible()) { socket.json.send({type: "talk_request", sendto: id }); } else { console.warn('max connections. so ignore call'); } return; } else if (evt.type === 'offer') { console.log("Received offer, set offer, sending answer....") onOffer(evt); } else if (evt.type === 'candidate' && isPeerStarted()) { console.log('Received ICE candidate...'); onCandidate(evt); } else if (evt.type === 'end_talk') { console.log("got talker bye."); detachVideo(id); // force detach video stopConnection(id); } } function onUserDisconnect(evt) { console.log("disconnected"); if (evt) { detachVideo(evt.id); // force detach video stopConnection(evt.id); } } function getRoomName() { // たとえば、 URLに ?roomname とする var url = document.location.href; var args = url.split('?'); if (args.length > 1) { var room = args[1]; if (room != "") { return room; } } return "_defaultroom"; } function onOffer(evt) { console.log("Received offer...") console.log(evt); setOffer(evt); sendAnswer(evt); } function onCandidate(evt) { var id = evt.from; var conn = getConnection(id); if (! conn) { console.error('peerConnection not exist!'); return; } var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate}); console.log("Received Candidate...") console.log(candidate); conn.peerconnection.addIceCandidate(candidate); } function sendSDP(sdp) { var text = JSON.stringify(sdp); console.log("---sending sdp text ---"); console.log(text); // send via socket socket.json.send(sdp); } function sendCandidate(candidate) { var text = JSON.stringify(candidate); console.log("---sending candidate text ---"); console.log(text); // send via socket socket.json.send(candidate); } // ---------------------- connection handling ----------------------- function prepareNewConnection(id) { var pc_config = {"iceServers":[]}; var peer = null; try { peer = new webkitRTCPeerConnection(pc_config); } catch (e) { console.log("Failed to create PeerConnection, exception: " + e.message); } var conn = new Connection(); conn.id = id; conn.peerconnection = peer; peer.id = id; addConnection(id, conn); // send any ice candidates to the other peer peer.onicecandidate = function (evt) { if (evt.candidate) { console.log(evt.candidate); sendCandidate({type: "candidate", sendto: conn.id, sdpMLineIndex: evt.candidate.sdpMLineIndex, sdpMid: evt.candidate.sdpMid, candidate: evt.candidate.candidate}); } else { console.log("on ice event. phase=" + evt.eventPhase); } }; //console.log('Adding local stream...'); //peer.addStream(localStream); peer.addEventListener("addstream", onRemoteStreamAdded, false); peer.addEventListener("removestream", onRemoteStreamRemoved, false) // when remote adds a stream, hand it on to the local video element function onRemoteStreamAdded(event) { console.log("Added remote stream"); //attachVideo(this.id, event.stream); remoteVideo.src = window.webkitURL.createObjectURL(event.stream); } // when remote removes a stream, remove it from the local video element function onRemoteStreamRemoved(event) { console.log("Remove remote stream"); detachVideo(this.id); } return conn; } function setOffer(evt) { var id = evt.from; var conn = getConnection(id); if (! conn) { conn = prepareNewConnection(id); conn.peerconnection.setRemoteDescription(new RTCSessionDescription(evt)); } else { console.error('peerConnection alreay exist!'); } } function sendAnswer(evt) { console.log('sending Answer. Creating remote session description...' ); var id = evt.from; var conn = getConnection(id); if (! conn) { console.error('peerConnection not exist!'); return; } conn.peerconnection.createAnswer(function (sessionDescription) { // in case of success conn.peerconnection.setLocalDescription(sessionDescription); sessionDescription.sendto = id; sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Answer failed"); }, mediaConstraints); } function sendRequest() { if (! socketReady) { alert("Socket is not connected to server. Please reload and try again."); return; } // call others, in same room console.log("send request in same room, ask for offer"); socket.json.send({type: "talk_request"}); } // stop the connection upon user request function hangUp() { console.log("Hang up."); socket.json.send({type: "bye"}); detachVideo(null); stopAllConnections(); } </script> </body> </html>