このエントリは ドワンゴ Advent Calendar の21日目の記事です。あと4日!完走できるといいですね。
前々から、技術的にはできるはずだよなーと思っていたものを、Advent Calendar 駆動で作った話をします。ちなみに作者はドワンゴに所属しておりますが、あくまでも個人として開発をしましたので、開発されたものはドワンゴとは無関係です。
作ったもの
表題のとおり、ブラウザだけでニコ生の生主に凸できる Chrome Extension です。生主と視聴者の両者がこの拡張を Chrome にインストールしていれば、それ以外のものは一切不要で生主に凸ができます。
生主側の画面の例をいくつか貼っておきます。
歴史的経緯
もともとニコ生には、ニコ電という生主と視聴者が会話できるシステムがありました。しかしながらこのシステムは、視聴者側が携帯電話を使って電話を掛けることになるため通話料金が発生するというデメリットがありました。お互いに個人情報をやり取りする必要がないなどの各種メリットがあったにも関わらず、最終的には今年の1月にサービス終了となりました。
ニコ電サービス終了以前から、生主と会話する(凸する)ツールとしてよく使われていたのは Skype です。しかしながら、凸するには当然 Skype の ID を交換する必要があります。また捨てアカを取るにしてもめんどくさい上、最近は Microsoft アカウントと Skype のアカウントが統合されているため、SkypeID の個人情報としての価値が少し増加しているようにも感じます。
そんなわけで、めんどくさい個人情報のやりとりもなく、かつ無料で簡単に使える凸ツールを作ろうと思い立ったわけです。
技術的な話
ブラウザだけでどうやって凸するのかというと、みなさんのご想像とおり WebRTC を使います。WebRTC の説明は不要だと思いますが、簡単にいうとブラウザ間で P2P 通信を行って、ビデオや音声を含んだ各種データをやり取りできる技術です。
WebRTCのライブラリとしては、PeerJS が有名ですが今回はそれをさらに独自拡張し、なおかつ無償でサーバまで提供してくれている SkyWay を使用しました。
PeerJS もサーバを無償で用意してくれていますが、同時接続数が50クライアントまでという制限があるため、ざっとドキュメント読んだ限りでは制限のなかった SkyWay を採用しました。また独自拡張 API である listAllPeers()
を使いたかったというのもあります。
躓いた話と乗り越えた話
Chrome Extension と SkyWay とドメイン
Chrome Extension も JavaScript で開発するので、まあすんなり WebRTC 使えるだろーって思っていました。今回は開いているニコ生のページに対して操作をするので、Chrome Extension のなかでも Content Scripts を使うことになるなーと思ってその通り作ってたのですが、どうやっても SkyWay のサーバから弾かれてしまいました。具体的には「API KEY ~~~ is invalid.」と言われてしまうのです。
SkyWay は、登録をすると API KEY を入手でき、個々の API KEY ごとに利用可能なドメインを指定できるようになっています。今回の場合、ニコ生のドメインは live.nicovideo.jp
なので当然それを登録していたのですが、ダメでした。悩んでいたときに Chrome Extension には個別の URL が存在していることを思い出したので chrome-extension://~~~/hogehoge.js の ~~~ 部分を利用可能なドメインとして登録してもみたのですが、それでもダメでした。
そこで今回とったのは、ニコ生のページに拡張の JavaScript を埋め込んでしまう方法です。Content Scripts はページ上のコンテキストで実行され、ページの DOM にもアクセス可能ではあるのですが、それでもページ上で直接実行されているわけではありません。そこで実行したい JavaScript をページの DOM を操作することで埋め込んでしまって、直接ページ上で実行させてしまえばいいと考えました。
http://qiita.com/suin/items/5e1aa942e654bce442f7 の記事を参考にさせていただいて、以下のようなコードを書いて nicototsu.js
と nicototsu.css
をページ上に埋め込むことに成功しました。
var injectScript = function(file, node) {
var s, th;
th = document.getElementsByTagName(node)[0];
s = document.createElement("script");
s.setAttribute("type", "text/javascript");
s.setAttribute("src", file);
return th.appendChild(s);
};
var injectStyleSheet = function(file, node) {
var l, th;
th = document.getElementsByTagName(node)[0];
l = document.createElement("link");
l.setAttribute("rel", "stylesheet");
l.setAttribute("type", "text/css");
l.setAttribute("href", file);
return th.appendChild(l);
}
injectStyleSheet(chrome.extension.getURL("/nicototsu.css"), "body");
injectScript(chrome.extension.getURL("/nicototsu.js"), "body");
またこのときの manifest ファイルは以下のようになります。
{
"manifest_version": 2,
"name": "ニコ凸",
"version": "0.0.1",
"description": "ニコニコ生放送で生主に凸ができるChrome拡張です。",
"content_scripts": [
{
"matches": ["http://live.nicovideo.jp/watch/*"],
"js": ["injector.js"]
}
],
"web_accessible_resources": [
"nicototsu.js",
"nicototsu.css"
]
}
web_accessible_resources
で埋め込むファイルを指定しておかないと失敗するので注意が必要です。
ニコ生のページがちょっとアレな件
まあ、ソース見てもらえればわかるんですが、ニコ生のページは jQuery と prototype.js が混在しており、$
は prototype.js のオブジェクトなんですね。Content Scripts でやろうとしてたきは、完全に JavaScript の実行空間が切り離されていたため、自由に最新の jQuery を使ったりできたのですが、ページに埋め込むからにはそのページにある材料で勝負するべきだと考え、久々に生の document.getElementById()
とか addEventListener()
などを使って DOM 操作をしました。
逆に、Content Scripts でやろうとしてたときには、自分のニコニコのIDや配信者のID、現在の番組番号(lv~)などをスクレイピングして取得していたのですが、埋め込まれたおかげでページ上の JavaScript のオブジェクトから取得できるようになったのはよかった点です。
ちなみにこういう大きなページの解析をする際には、Chrome のデベロッパーツールはデフォルトの横分割表示ではなく、縦分割表示にした方が捗りますね。特に最近は解像度がワイドなディスプレイが多いので。
僕もこの前の東京Node学園祭で知ったのですが、ここを長押しすると縦分割表示にするメニューがでてきます。
状態の管理が想像以上にめんどくさかった
まず、生主と視聴者の2つのロールがあります。そしてそれぞれについて以下のような状態が考えられます。
- 生主
- 視聴者が誰もこの拡張を導入していない
- 拡張を導入してる視聴者が一人以上存在する
- 凸リクエスト受付中
- 凸リクエストされ中
- 凸通話中
- 視聴者
- 生主がこの拡張を導入していない
- 生主が凸リクエストを受け付けていない
- 生主が凸リクエスト受付中
- 凸リクエスト中
- 凸通話中
他にも、他の視聴者がリクエストや通話している状態があったり、細かいところではブラウザがマイクの使用を許可するダイアログを出してから「許可」が押されるまでの状態があったりと、勢いでガーッと作るにはちょっとめんどくさすぎました。これからちゃんと State パターンなどを使ってリファクタリングをしようと思っています。
マルチバイト文字の問題
状態の変更があったらそれを生主や視聴者に通知する必要があるのですが、そこは PeerJS の DataConnection
を使っています。そこで、例えば視聴者から凸リクエストが来たときに、誰からのリクエストかわかるようにユーザ名を添えて send()
するのですが、そのユーザ名がマルチバイト文字だった場合に文字化けしてしまう問題がありました。
問題に気付くまでは、普通に JSON を JSON.stringify()
で文字列にして送っていたのですが、文字化けが発生したことで解決策をさがしたところ、PeerJS の peer.connect()
の第2引数の options
でシリアライズの方法を指定できることに気づきました。そこで
var connection = peer.connect(remoteId, {"serialization": "json"});
のようにしてやることによって、マルチバイト文字での文字化けが解消し、さらにいちいち送受信の際に JSON.stringify()
や JSON.parse()
をする必要もなくなりました。
Windows と Mac の Chrome の違い
今回の Chrome Extension を作りにあたっては、生主と凸する視聴者と凸してない視聴者の3つのアカウントで同時にニコ生の画面を見ないといけないため、開発に3台のマシンを使用しました(VM でやるとかやりようはあったでしょうけど)。で、そのうちの1台のOSが Windows 8.1 でした。そこで何度かはまった落とし穴が Chrome の微妙な違いでした。
Mac の場合はマイクの使用許可を求める通知が「拒否」「許可」の順でボタンが並んでいるのですが、なぜか Windows の場合は逆順になります。
これのせいで、Windows で何度か「拒否」を押してしまい、「設定」>「詳細設定を表示...」>「プライバシー/コンテンツの設定」>「メディア/例外の管理...」という深いメニューを辿って「拒否」に設定してしまった live.nicovideo.jp
の設定を削除するということを繰り返す羽目になりました(逆にここで「許可」を設定しておけばいちいち聞かれはしないのですが、それはそれで聞かれてから「許可」を押すまでの動作を確認ができなくなってしまうのです)。
と、ここまで書いて上の SS 取ってたら、拒否した場合ブラウザのアドレス欄にビデオに×マークのアイコンでるので、そこをクリックすればすぐメディアの例外管理メニューまで飛べることに気がつきました...。
audio タグの muted 属性
最後はほんとにしょうもないミスだったのですが、以前他の WebRTC 使ったプロダクトを作っていた際に、デフォルトで通話相手をミュートにするために
<video autoplay="true" muted="true"></video>
みたいなコードを書いていたんですね。で、今回もそれに倣って、ただ音声はデフォルトでミュートにしたくなかったので
<audio autoplay="true" muted="false"></audio>
みたいにしました。で、大体できたのでいざ通話をしてみようとしたところ、全く相手と通話ができないのです。なんでだろうとしばらく悩んで色々試した挙句、audio
タグを video
タグに変えてビデオ通話ができるようにしてみたところ、ビデオは見られるけど音が来てない状態になり、これもしかして音が聞こえてないだけなのではと思い当りました。でも muted は false なので Volume がなんか知らんけど 0 になっちゃうのかなーとか思って Volume を 1 に設定したりもしたんだけど、それでもだめで。
で、ふと、ああ muted ってもしかして属性値は関係なくて属性が存在するだけでミュートになるのでは、と思い当り、属性を削除してみたところ音声が聞こえるようになりました。あとで調べたところ、ボリュームの設定よりもミュートフラグの方が優先されるようですね。そりゃそうですね。
公開について
そんなこんなで作ったのですが、まだちょっとバグが残ってます。基本的な動作に問題はないのですが、同じ視聴者が2回以上凸した場合に、最初のストリームが(音声は当然聞こえないのですが)ちゃんと閉じられていないかなにかで、マイクの使用許可を2回聞いてきたりとかする状態です。その辺のバグをつぶして、あとは上で言ったリファクタリングとかを済ませたら Chrome ウェブストアに公開しようと思います(もう5ドル払ったので)。お楽しみに。