[Signagify 4] Chromecast の Custom Receiver アプリで独自アプリ作ってみる

この記事はファーストサーバのAdvent Calendar 2015の１８日目として書きました。
http://qiita.com/advent-calendar/2015/firstserver

内容は１７日目の続きです。
http://qiita.com/vanx2/items/3c20bf8e4111da9eb68d

前回簡単なChromecastアプリを作って動画再生してみましたが、今回はReceiverアプリを自分で実装してみます。

# Receiverアプリ

今回はCustom Receiverアプリを使って独自でReceiverアプリを実装します。
シンプルなHTMLに動画を表示します。
SDKを読み込み、メディアの管理をする`MediaManager`とReceiverアプリとしてSenderと通信する`castReceiverManager`を使います。

```html:index.html
<!DOCTYPE html>
<html>
<head>
  <!-- SDK 読み込み -->
  <script type="text/javascript" src="//www.gstatic.com/cast/sdk/libs/receiver/2.0.0/cast_receiver.js"></script>
</head>
<body>
  <!-- 動画表示エレメント -->
  <video id="vid" />
  <script type="text/javascript">
    window.onload = function() {

      // ログレベルを設定
      cast.receiver.logger.setLevelValue(cast.receiver.LoggerLevel.DEBUG);

      // MediaManager でコントロールしたいvideoエレメントを取得
      window.mediaElement = document.getElementById('vid');
      // MediaManager の開始
      window.mediaManager = new cast.receiver.MediaManager(window.mediaElement);

      // CastReceiverManager を取得
      window.castReceiverManager = cast.receiver.CastReceiverManager.getInstance();
      // CastReceiverManager の開始
      castReceiverManager.start();

    };
  </script>
</body>
</html>
```

### MediaManager
動画再生などの基本的な動作を行います。イベントをオーバーライドすることで処理を加える事ができます。

### CastReceiverManager
Senderアプリとの通信をします。イベントをオーバーライドすることで処理を加える事ができます。

上記で作ったReceiverアプリをGoogle Driveなどのhttpsでアクセスできるところで公開し、「Google Cast SDK Developer Console」にそのURLを登録します。登録すると`AppId`が発行されます。SenderアプリからReceiverアプリを起動させるときにこの`AppId`を使います。


# Senderアプリ
それではSenderアプリからChromecastへ上記のReceiverアプリを実行するようリクエストし、起動後動画再生をリクエストしてみます。

前回はcastv2-clientというモジュールを使いましたが、今回はより抽象度の低いcastv2を使います。
https://github.com/thibauts/node-castv2

castv2は、SenderからChromecastの`8009`ポートにTLSで接続してお互いにメッセージを投げ合います。
https://github.com/thibauts/node-castv2/blob/master/README.md

メッセージは`namespace`で定義されたチャンネルを通して投げられます。
例）接続の場合の`namespace`：`urn:x-cast:com.google.cast.tp.connection`

メッセージはJSON形式で記述されます。
例）接続の場合のメッセージ：`{TYPE: "CONNECT"}`

それでは実際にやってみましょう。

インストールしてー

```shell-session
$ npm i castv2
```

スクリプト書いてー

```js:sender.js
var Client = require('castv2').Client;
var host = '192.168.1.63'; // Chromecastのアドレス
var appID = 'D86E10F8'; // Receiver アプリの appID

  client = new Client();

  // TLSで接続
  client.connect(host, function() {

    // namespace ごとにチャンネルを作る
    // 接続系
    var connection = client.createChannel('sender-0', 'receiver-0', 'urn:x-cast:com.google.cast.tp.connection', 'JSON');
    // ハートビート系
    var heartbeat  = client.createChannel('sender-0', 'receiver-0', 'urn:x-cast:com.google.cast.tp.heartbeat', 'JSON');
    // レシーバー系
    var receiver   = client.createChannel('sender-0', 'receiver-0', 'urn:x-cast:com.google.cast.receiver', 'JSON');
    // メディア系
    var media      = client.createChannel('sender-0', 'receiver-0', 'urn:x-cast:com.google.cast.media', 'JSON');

    // IDs
    var clientId   = 'client-1';
    var transportId = '';

    // connectionチャンネルでメッセージを受け取った時の処理
    connection.on('message', function(data, broadcast) {
    });

    // TLSでつながったコネクション上で接続のメッセージを投げる
    connection.send({ type: 'CONNECT' });

    // ハートビートのピンポンを開始
    setInterval(function() {
      // 5秒おきに Chromecast へ PING を送る（PONG が返ってくる）
      heartbeat.send({ type: 'PING' });
    }, 5000);

    // Receiverアプリを起動
    receiver.send({ type: 'LAUNCH', appId: appID, requestId: 5 });

    // Receiverアプリからメッセージを受け取った時の処理
    receiver.on('message', function(data, broadcast) {
      // Receiverアプリ起動時に requestId: 5 を指定したので、
      // その返事として返って来たメッセージだったら
      if (data.requestId == 5) {
        // 返って来た RECEIVER_STATUS をログ出力
        console.log(data.status.applications[0]);
        // 今後送るリクエストで RECEIVER_STATUS の transportId を使うので保管しておく
        transportId = data.status.applications[0].transportId;

        // 送られてきた transportId を destinationId としてセットして再度接続
        connection.destinationId = transportId;
        connection.sourceId = clientId;
        connection.send({ type: 'CONNECT' });

        // 同様に transportId を destinationId としてセットして
        media.destinationId = transportId;
        media.sourceId = clientId;
        // 動画再生のリクエスト投げる
        media.send({
          requestId: 9,
          type: 'LOAD',
          media: {contentId: "http://commondatastorage.googleapis.com/gtv-videos-bucket/big_buck_bunny_1080p.mp4",
                  streamType: "buffered",
                  contentType: "video/mp4",
                  metadata: null,
                  duration: 596,
                  customData: null},
          autoplay: true,
          currentTime: 0,
          customData: null,
          activeTrackIds: [],
        });
      }
    });
  });

```

`DEBUG=* node hoge.js`と実行することで、castv2が送受信のログを出してくれます。

```shell-session
$ DEBUG=* node sender.js
  castv2 connecting to 192.168.xx.63:8009 ... +0ms
  castv2 connected +191ms
  castv2 send message: protocolVersion=0 sourceId=sender-0 destinationId=receiver-0 namespace=urn:x-cast:com.google.cast.tp.connection data={"type":"CONNECT"} +2ms
  castv2 send message: protocolVersion=0 sourceId=sender-0 destinationId=receiver-0 namespace=urn:x-cast:com.google.cast.receiver data={"type":"LAUNCH","appId":"D86E10F8","requestId":5} +4ms
  castv2 recv message: protocolVersion=0 sourceId=receiver-0 destinationId=* namespace=urn:x-cast:com.google.cast.receiver data={"requestId":0,"status":{"isActiveInput":true,"isStandBy":false,"volume":{"level":1.0,"muted":false}},"type":"RECEIVER_STATUS"} +229ms
  castv2 recv message: protocolVersion=0 sourceId=receiver-0 destinationId=* namespace=urn:x-cast:com.google.cast.receiver data={"requestId":5,"status":{"applications":[{"appId":"D86E10F8","displayName":"ac","namespaces":[{"name":"urn:x-cast:com.google.cast.media"},{"name":"urn:x-cast:com.oreore.message"}],"sessionId":"085B7D2E-A1AE-4205-B6B3-C891D7438A77","statusText":"ac","transportId":"web-19"}],"isActiveInput":true,"isStandBy":false,"volume":{"level":1.0,"muted":false}},"type":"RECEIVER_STATUS"} +881ms
{ appId: 'D86E10F8',
  displayName: 'ac',
  namespaces:
   [ { name: 'urn:x-cast:com.google.cast.media' },
     { name: 'urn:x-cast:com.oreore.message' } ],
  sessionId: '085B7D2E-A1AE-4205-B6B3-C891D7438A77',
  statusText: 'ac',
  transportId: 'web-19' }
  castv2 send message: protocolVersion=0 sourceId=client-1 destinationId=web-19 namespace=urn:x-cast:com.google.cast.tp.connection data={"type":"CONNECT"} +5ms
  castv2 send message: protocolVersion=0 sourceId=client-1 destinationId=web-19 namespace=urn:x-cast:com.google.cast.media data={"requestId":9,"type":"LOAD","media":{"contentId":"http://commondatastorage.googleapis.com/gtv-videos-bucket/big_buck_bunny_1080p.mp4","streamType":"buffered","contentType":"video/mp4","metadata":null,"duration":596,"customData":null},"autoplay":true,"currentTime":0,"customData":null,"activeTrackIds":[]} +0ms
  castv2 recv message: protocolVersion=0 sourceId=web-19 destinationId=* namespace=urn:x-cast:com.google.cast.media data={"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"BUFFERING","currentTime":0,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"media":{"contentId":"http://commondatastorage.googleapis.com/gtv-videos-bucket/big_buck_bunny_1080p.mp4","streamType":"buffered","contentType":"video/mp4","metadata":null,"duration":596.501333,"customData":null},"currentItemId":1,"items":[{"itemId":1,"media":{"contentId":"http://commondatastorage.googleapis.com/gtv-videos-bucket/big_buck_bunny_1080p.mp4","streamType":"buffered","contentType":"video/mp4","metadata":null,"duration":596.501333,"customData":null},"autoplay":true,"activeTrackIds":[],"customData":null}],"repeatMode":"REPEAT_OFF"}],"requestId":9} +3s
  castv2 send message: protocolVersion=0 sourceId=sender-0 destinationId=receiver-0 namespace=urn:x-cast:com.google.cast.tp.heartbeat data={"type":"PING"} +24ms
  castv2 recv message: protocolVersion=0 sourceId=receiver-0 destinationId=sender-0 namespace=urn:x-cast:com.google.cast.tp.heartbeat data={"type":"PONG"} +22ms
  castv2 recv message: protocolVersion=0 sourceId=web-19 destinationId=* namespace=urn:x-cast:com.google.cast.media data={"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PLAYING","currentTime":0.35419,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":0} +1s
  castv2 send message: protocolVersion=0 sourceId=sender-0 destinationId=receiver-0 namespace=urn:x-cast:com.google.cast.tp.heartbeat data={"type":"PING"} +3s
  castv2 recv message: protocolVersion=0 sourceId=receiver-0 destinationId=sender-0 namespace=urn:x-cast:com.google.cast.tp.heartbeat data={"type":"PONG"} +48ms
  castv2 recv message: protocolVersion=0 sourceId=web-19 destinationId=* namespace=urn:x-cast:com.google.cast.media data={"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PLAYING","currentTime":4.245333,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":0} +1s
  castv2 recv message: protocolVersion=0 sourceId=web-19 destinationId=* namespace=urn:x-cast:com.google.cast.media data={"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PLAYING","currentTime":5.765537,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":0} +2s
  castv2 send message: protocolVersion=0 sourceId=sender-0 destinationId=receiver-0 namespace=urn:x-cast:com.google.cast.tp.heartbeat data={"type":"PING"} +877ms
  castv2 recv message: protocolVersion=0 sourceId=receiver-0 destinationId=sender-0 namespace=urn:x-cast:com.google.cast.tp.heartbeat data={"type":"PONG"} +8ms
  castv2 recv message: protocolVersion=0 sourceId=web-19 destinationId=* namespace=urn:x-cast:com.google.cast.media data={"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PLAYING","currentTime":6.458583,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":0} +1s
```

ってなかんじで動画が再生されましたかー？

`{ reason: 'NOT_FOUND', requestId: 1, type: 'LAUNCH_ERROR' }`
と返ってくる場合は、指定した`AppId`が無いわって怒ってるので、登録されたReceiverアプリのAppIdと間違ってないか確認して、間違って無ければ一度Chromecastを再起動してみてください。

#実装のポイント
処理の流れに沿ってどういうメッセージをやりとりしているのか順に見ていきます。

##接続
TLSで接続し、`connection`チャンネルで`{ type: 'CONNECT' }`を投げるのですが、実際には下記のようなメッセージが送られます。

```
protocolVersion=0
sourceId=sender-0
destinationId=receiver-0
namespace=urn:x-cast:com.google.cast.tp.connection
data={"type":"CONNECT"}
```

`protocolVersion`は今のところ決め打ちで`0`をつけています。

`sourceId`は最初`sender-[Number]`を指定しているようです。後ほど`Client-[Number]`で上書くので`[Number]`は特に乱数や重複など気にせず`0`固定にしています。

`destinationId`も`Receiver-[Number]`を指定し、後ほどReceiverアプリから返ってくる`transportId`で上書きするので`0`固定にしています。

`namespace`が`urn:x-cast:com.google.cast.tp.connection`というチャンネルを通して`data={"type":"CONNECT"}`というデータを送っていることがわかりますね。

接続が完了すると`receiver`チャンネルで下記メッセージが送られてきます。

```
protocolVersion=0
sourceId=receiver-0
destinationId=*
namespace=urn:x-cast:com.google.cast.receiver
data={
  "requestId":0,
  "status":{
    "isActiveInput":true,
    "isStandBy":false,
    "volume":{
      "level":1.0,
      "muted":false
    }
  },
  "type":"RECEIVER_STATUS"
}
```

`sourceId`にはさきほど`CONNECT`を送るときに指定した`destinationId`がついてきます。

`destinationId`が`*`となっているのでブロードキャストなメッセージだということがわかります。

`namespace`が`urn:x-cast:com.google.cast.receiver`というチャンネルを通して
JSONデータが送られてきています。

これは`RECEIVER_STATUS`ということで簡単なReceiverアプリのステータスが返って来ていますが詳細なステータスは載っていません。`{"type": "GET_STATUS","requestId": [Number]}`を送ると詳細な`RECEIVER_STATUS`が返って来ます。Receiverアプリを実行する`LAUNCH`を送った際にも同様の詳細な`RECEIVER_STATUS`が返ってくるので後ほどそこで中身を見てみます。

メッセージに問題があるか、Chromecast上でエラーが発生した場合は`{"type":"CLOSE"}`が返って来ます。直前にエラーメッセージが送られてくる場合もあります。

##接続の維持
ハートビートチャンネルでピンポンすることで接続を維持しています。

Senderから`PING`

```
protocolVersion=0 
sourceId=sender-0 
destinationId=receiver-0 
namespace=urn:x-cast:com.google.cast.tp.heartbeat 
data={"type":"PING"}
```

Chromecastから`PONG`

```
protocolVersion=0 
sourceId=receiver-0 
destinationId=sender-0 
namespace=urn:x-cast:com.google.cast.tp.heartbeat 
data={"type":"PONG"}
```


##アプリの実行
`receiver`チャンネルで`LAUNCH`を投げます。

```
protocolVersion=0 
sourceId=sender-0 
destinationId=receiver-0 
namespace=urn:x-cast:com.google.cast.receiver 
data={
  "type":"LAUNCH",
  "appId":"D86E10F8",
  "requestId":5
}
```

`requestId`にはリクエストごとに固有のIDをつけます。Receiverが返してくる`RECEIVER_STATUS`に`requestId`がくっついてくるのでそれを元にどのリクエストに対するメッセージなのか判断します。

##Receiverアプリのステータス

Receiverアプリが起動すると`RECEIVER_STATUS`が返って来ます。

```
protocolVersion=0 
sourceId=receiver-0 
destinationId=* 
namespace=urn:x-cast:com.google.cast.receiver 
data={
  "requestId":5,
  "status":{
    "applications":[{
          "appId":"D86E10F8",
          "displayName":"ac",
          "namespaces":[
            {"name":"urn:x-cast:com.google.cast.media"},
            {"name":"urn:x-cast:com.oreore.message"}],
          "sessionId":"085B7D2E-A1AE-4205-B6B3-C891D7438A77",
          "statusText":"ac",
          "transportId":"web-19"
    }],
    "isActiveInput":true,
    "isStandBy":false,
    "volume":{
      "level":1.0,
      "muted":false}},
  "type":"RECEIVER_STATUS"
}
```

`status`の中の`AppID`でどのアプリが動いているかわかります。

|アプリ|AppID|
|---|---|
|BackDrop|E8C28D3C|
|YouTube|CC1AD845|
|Mirroring|0F5096E8|


ここで大事なのは`transportId`と`sessionId`です。`transportId`は今後そのアプリに対してメッセージを投げる際に`destinationId`に指定します。`sessionId`はアプリの停止時などに指定します。

アプリを起動せずにReceiverのステータスを取得したい場合は`GET_STATUS`を送ります。

```
protocolVersion=0 
sourceId=sender-0 
destinationId=receiver-0 
namespace=urn:x-cast:com.google.cast.receiver 
data={"type":"GET_STATUS","requestId":1}
```



##再接続
ここで、さきほど受け取った`transportId`を使って再接続（TLSでの接続はしたままでCONNECTをもう一度投げる）をしないとReceiverアプリとの通信ができません。

```
protocolVersion=0 
sourceId=client-1 
destinationId=web-19 
namespace=urn:x-cast:com.google.cast.tp.connection 
data={"type":"CONNECT"} 
```

`sourceId`には`client-[Number]`でSenderが割り振ったIDを指定します。
`destinationId`にさきほど受け取った`transportId`を指定します。


##動画の再生

これでようやくReceiverアプリ上の`MediaManager`にメッセージを送ることができるようになります。

```
protocolVersion=0 
sourceId=client-1 
destinationId=web-19 
namespace=urn:x-cast:com.google.cast.media 
data={
  "requestId":9,
  "type":"LOAD",
  "media":{
    "contentId":"http://commondatastorage.googleapis.com/gtv-videos-bucket/big_buck_bunny_1080p.mp4",
    "streamType":"buffered",
    "contentType":"video/mp4",
    "metadata":null,
    "duration":596,
    "customData":null
  },
  "autoplay":true,
  "currentTime":0,
  "customData":null,
  "activeTrackIds":[]} 
```

`media`の`contentId`に再生したい動画のURLを指定します。`contentId`以外は任意です。

##メディアのステータス
動画の再生が始まると`MediaManager`の`MEDIA_STATUS`が返って来ます。


```
protocolVersion=0 
sourceId=web-19 
destinationId=* 
namespace=urn:x-cast:com.google.cast.media 
data={
  "type":"MEDIA_STATUS",
  "status":[{
    "mediaSessionId":1,
    "playbackRate":1,
    "playerState":"BUFFERING",
    "currentTime":0,
    "supportedMediaCommands":15,
    "volume":{
      "level":1,
      "muted":false
    },
    "activeTrackIds":[],
    "media":{
      "contentId":"http://commondatastorage.googleapis.com/gtv-videos-bucket/big_buck_bunny_1080p.mp4",
      "streamType":"buffered",
      "contentType":"video/mp4",
      "metadata":null,
      "duration":596.501333,
      "customData":null
    },
    "currentItemId":1,
    "items":[{
      "itemId":1,
      "media":{
        "contentId":"http://commondatastorage.googleapis.com/gtv-videos-bucket/big_buck_bunny_1080p.mp4",
        "streamType":"buffered",
        "contentType":"video/mp4",
        "metadata":null,
        "duration":596.501333,
        "customData":null
      },
      "autoplay":true,
      "activeTrackIds":[],
      "customData":null
    }],
  "repeatMode":"REPEAT_OFF"}],
  "requestId":9
}
```

`LOAD`を送った際につけた`requestId: 9`がついてきていますね。

一旦それぞれの細かいプロパティの解説は割愛します。
https://developers.google.com/cast/docs/reference/messages

`repeatMode: "REPEAT_OFF"`というのがあるので`LOAD`時に`repeatMode: "REPEAT_SINGLE"`などを指定するとループ再生するかと思いきや、うまく動作しませんでした（2015/12/22現在。そのうち動くかも）。

以前は`items`ってなかった気がするんだけど、Chromecast2で提供され始めている"Fast Play"機能のために動画のキューイングができるよう対応されたのかな。



# ReceiverアプリのDebug
Chromeで`9222`ポートにアクセスして`Remote Debugging (AppEngine)`を開きます。
最初は何も表示されていませんがアドレス欄のシールドをクリックして「安全でないスクリプトを読み込む」を押すとデバッグ情報が出るようになります。

Chromecastで起動しているアプリが変わるとデバッグ画面を再度開き直す必要があります。

詳細はこちら
https://developers.google.com/cast/docs/debugging

----

ぜーはー自分で`namaspace`定義してメッセージの送受信までやりたかったけどもうすぐAdvent Calendarの公開時間なので次回また、、、あれば、、、

コメントもらえたらやる気出して書くかもですｖ