ウェブページを2画面対応にするPresentation APIを使ってみる

ウェブページでは既にFullscreen APIで全画面表示にすることはできますが、現在表示中のページをそのままにして、例えばスライドショーやプレゼンテーションを別画面で全画面表示する、といったことができればもっと便利になります。

Chromeでは、外部接続のディスプレイやChromecast等(以下、レシーバ)に別のウェブページを表示させて、ブラウザ側のデバイス(以下、コントローラ)から操作できる、[Presentation API](https://w3c.github.io/presentaiton-api)が実装されていますので、その使い方を簡単に説明します。

Chrome 59以降ではChromecastやAndroid TVへのキャスト、Chrome 66以降では[HDMI等のケーブル接続の外部モニタへの表示に対応しています](https://developers.google.com/web/updates/2018/04/present-web-pages-to-secondary-attached-displays)。現時点での対応プラットフォームは、Chrome OS, Linux, Windows, macOS (外部接続モニタのみChrome 69以降)となります。

## はじめに

まず、Presentation APIはHTTPSでホストされているウェブページでしか動作しません。Service Workers等と同様ですね。

## コントローラ側API

まず、レシーバ側にロードさせたいURLを次のようにして指定します。当然ながら、レシーバ側URLも`https://`のオリジンになっている必要があります。

```js
const request = new PresentationRequest('viewer.html');
```

### ディスプレイの有無をモニタする

実際にディスプレイへの表示を始める前に、対応するディスプレイがあるかどうかをモニタすることが出来ます。

```js
const monitorAvailability = async () => {
  const availability = await request.getAvailability();
  console.log('ディスプレイ: ' + (availability.value ? 'あり' : 'なし'));
  availability.addEventListener('change', () => {
    console.log('ディスプレイ: ' + (availability.value ? 'あり' : 'なし'));
  });
};

monitorAvailability();
```

但し、ディスプレイの状態をモニタする時は、Chromecast等のデバイスをネットワーク上でモニタするため、バッテリー消費が多くなりますので、特にモバイル環境に配慮する場合は注意が必要です。

### レシーバ側にウェブページを表示

`request.start()`でレシーバに指定したURLのウェブページを表示させることが出来ます。

```js
let connection;

const startPresentation = async () => {
  try {
    connection = await request.start();
    connection.addEventListener('connect', () => {
      console.log('ディスプレイに接続しました');
    });
  } catch (e) {
    console.log('接続に失敗したか、接続をキャンセルしました。');
  }
};

startPresentation();
```

ディスプレイの状態をモニタしているかどうかにかかわらず、`request.start()`を実行すると、ブラウザは利用可能なディスプレイを検索し、ディスプレイの一覧を表示します。

<img width="375" alt="Screen Shot 2018-04-18 at 17.03.05 .png" src="https://qiita-image-store.s3.amazonaws.com/0/48556/e954e1b6-b159-7a78-269c-dfb015eff24e.png">

ここで、一つもディスプレイが見つからなかった場合、あるいは、ユーザの操作によってキャンセルされた場合は、エラーが返されます。(`request.start()`の戻り値はPromiseのため、async/awaitではtry/catch、Promiseの書式であれば`request.start().catch()`等でエラー処理を行います。)

### 一時的にディスプレイとの接続を閉じる/再接続する

Presentation APIでは、レシーバ側の状態をそのままにしてコントローラ側のウィンドウやタブを閉じることができるような仕組みが用意されています。

まず、一時的に接続を閉じる手順は次のような要領となります。

```js
localStorage.setItem('presentationId', connection.id);
connection.close();
```

上記のように、`connection`のIDを保存しておくと、レシーバ側の画面が生きている間であれば、次のような要領で再接続が可能です。なお、Chromeの場合、ウィンドウやタブを閉じるだけではレシーバ側は閉じられませんが、Chromeブラウザ自体を終了するとレシーバも閉じられてしまい、IDを使った再接続はできなくなりますので、ご注意下さい。

```js
const reconnectPresentation = async () => {
  const presentationId = localStorage.getItem('presentationId');
  try {
    connection = await request.reconnect(presentationId);
    connection.addEventListener('connect', () => {
      console.log('ディスプレイに再接続しました');
    });
  } catch (e) {
    console.log('再接続に失敗したか、再接続先が存在しませんでした。');
  }
};

reconnectPresentation();
```

### コネクションの状態

`connection.state`でコネクションの状態を確認できます。値は文字列です。

- `connecting`: レシーバと接続中
- `connected`: レシーバと接続完了
- `closed`: レシーバとの接続を一時的に切断
- `terminated` レシーバと切断し、レシーバが完全に終了

### レシーバを完全に終了する

レシーバを完全に終了するには、`connection.terminate()`を実行します。なお、コネクションの状態が`closed`の場合、一度再接続してからでなければ切断が出来ない点に注意が必要です。

```js
if (connection.state !== 'closed')
  connection.terminate();
else {
  connection.addEventListener('connect', () => {
    connection.terminate();
  });
  connection.reconnect(connection.id);
}
```

## レシーバ側API

Presentation APIにおいて、レシーバはコントローラから独立したブラウザとして動作します。

単に、指定されたURLがレシーバ側の画面に表示されればよいのであれば、レシーバ側では特別な対応は何も必要ありません。

一方、Presentation APIでは、コントローラとレシーバの間で通信できる仕組みが用意されています。まず、コントローラとの接続状態を次のような要領で取得します。仕様上は複数のコントローラと一つのレシーバが接続できるようになっていますが、実用上はコントローラとレシーバを一対一として実装をより簡単にしても構いません。

```js
const connections = [];

const addConnection = connection => {
  connections.push(connection);
  // コントローラ側で接続を閉じた時に接続リストから削除
  // (レシーバ側では再接続時に新しいconnectionが生成される)
  connection.addEventListener('close', event => {
    const closedConnection = event.target;
    if (connections.includes(closedConnection)) {
      connections.splice(connections.indexOf(closedConnection), 1);
    }
  });
};

const getConnections = async () => {
  if (navigator.presentation && navigator.presentation.receiver) {
    const list = await navigator.presentation.receiver.connectionList;
    // 通常では、レシーバ側のページを読み込んだ直後は、コントローラとの接続が
    // ただ一つだけlistに入っている
    list.connections.forEach(connection => {
      addConnection(connection);
    });
    // コントローラとの接続の追加をモニター
    list.addEventListener('connectionavailable', event => {
      addConnection(event.connection);
    });
  }
}

getConnections();
```

### コントローラとレシーバの通信

Presentation APIでは、コントローラとレシーバの間の通信を、WebSocketやWebRTCのRTCDataChannelと同じ要領でできる様になっています。コントローラとレシーバでAPIは共通です。

```js:データの受信
connection.addEventListener('message', event => {
  console.log('受信したデータ: ' + event.data);
});
```

```js:データの送信
connection.send('メッセージを送信します');
```

なお、WebSocketでの`CLOSING`に相当する状態がなく、`connection.close()`や`connection.terminate()`を実行すると、即座に`closed`や`terminated`の状態に移る点に注意が必要です。

## 参考: デモ

GoogleがPresentation APIの挙動をチェックできるデモページを用意しています:
https://googlechrome.github.io/samples/presentation-api/

また、参考までに筆者もデモページを作成してみました。
デモ: https://labs.othersight.jp/presentation-api-demo/
コード: https://github.com/tomoyukilabs/presentation-api-demo

## その他、注意点

- レシーバ側はコントローラ側と完全に分離されていますので、localStorageやIndexedDB、Service Workerやクッキー等が両者で全く共有されません。レシーバでページが読み込まれてからterminateされるまでの間はこれらのストレージ等がレシーバでも利用できますが、terminateされてレシーバが終了すると、それらの内容は完全にリセットされます。
- レシーバ側では、カメラやマイク、GPS、プッシュ通知等の機能が利用できないようになっています。
- Chromecast等にWi-Fi経由でキャストする場合、現時点では、Chromeブラウザの内部では、レシーバの画面を描画してリアルタイム動画として圧縮し、Chromecast等にストリーミング送信する仕組みになっています。この場合、バッテリーの消費に注意が必要です。