こんにちは!がねこまさしです。「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>