モバイルブラウザのビデオ再生がいろいろ変わるので確かめてみた

以前、[モバイルブラウザのビデオ再生でアプリキャッシュが使えるかを再度検証](http://qiita.com/tomoyukilabs/items/2c929e614cc12ec40002)という記事を書きまして、その頃は、モバイルブラウザではビデオの自動再生ができなかったり、キャッシュが効かなかったりと、なかなか大変でした。

ここ最近になって、この辺りのユーザ体験(UX)を改善しようと、Chrome for AndroidやSafari for iOSでいろいろ変更が試みられていますので、いくつか確かめてみました。

なお、Chrome for Androidの更新内容は、Google Developersの記事「[Service worker caching, playbackRate and blob URLs for audio and video on Chrome for Android](https://developers.google.com/web/updates/2016/06/ump?hl=en)」で紹介されていますので、詳細は当該記事をご覧下さい。(本記事も当該記事を参考にしています。)

# 自動再生の仕様変更 (Safari, Chrome)

[Safari for iOS 10](https://webkit.org/blog/6784/new-video-policies-for-ios/)と[Chrome for Android 53](https://www.chromestatus.com/feature/4864052794753024)では、「音声を鳴らさないという条件で」ページ読み込み後のビデオの自動再生が可能となります。

Chromeでは、**`<video>`要素に`muted`属性を指定している場合**に限り、`autoplay`属性の追加で自動再生が可能になります。

一方、Safariでは、`muted`属性が指定されている場合に加えて、**再生対象となるビデオのファイルにオーディオトラックが含まれていない場合**も(この場合は`muted`属性の指定がなくても)、`autoplay`属性による自動再生が可能となります。

```html:ビデオの自動再生の例(Chrome,Safari)
<video src="video.mp4" muted autoplay></video>
```
```html:オーディオトラックなしのビデオの自動再生の例(Safari)
<video src="video-with-no-audio.mp4" autoplay></video>
```

実際の挙動としては、**スクロール等で画面から見える状態になった時に自動再生が開始**されます。さらに、再生中にスクロールで見えない状態になると、自動的に再生を一時停止します(Chrome for Androidでは[58以降で対応しています](https://www.chromestatus.com/feature/5651339115757568)。)

また、Chrome、Safariともに、上記の条件を満たしている場合は、**ユーザジェスチャをトリガーとしなくても、`HTMLVideoElement.play()`によってスクリプトから再生開始することも可能**になります。スクリプト側の操作で再生開始する場合は、ビデオが画面に見えていなくても再生開始が可能となり、スクロール等で画面から見えない状態になっても再生が自動的に停止したりはしません。

なお、Chrome、Safariともに、`HTMLVideoElement.play()`メソッドが`Promise`を返すようになっています。ここで、もし上記の自動再生の条件が満たされていない時に`play()`メソッドでスクリプト側から再生開始しようとすると、`Promise`が`reject`されます。

```js:スクリプトから再生
const video = document.querySelector('video');
const play = video.play();
if (play instanceof Promise) {
  play.catch(error => {
    console.error('自動再生できません');
  });
}
```

# インライン再生の対応 (Safari)

[Safari for iOS 10](https://webkit.org/blog/6784/new-video-policies-for-ios/)では、ようやくiPhone等でもビデオのブラウザ内インライン再生が可能になります。具体的な方法ですが、`<video>`要素に`playsinline`属性を記述します。(iOS 9以前のWebViewでは`webkit-playsinline`属性としてサポートされていました。)

```html:インライン再生への対応の例(Safari)
<video src="video-for-inline-playback.mp4" playsinline></video>
```

特に、iOS 11でMediaStreamのビデオトラックの表示(getUserMediaやWebRTC等)に`<video>`要素を使う場合、[iPhoneでは`playsinline`属性がないと表示できない](http://qiita.com/massie_g/items/899345db7debd00e3885#comment-766ad76269c139a837e2)ので、注意が必要です。

# Blobの再生 (Chrome)

Chrome for Android 52では、**Blobとして取得したビデオファイルの再生にも対応**します。

```html:Blobの再生の例(HTML)
<video id="playblob"></video>
```

```js:Blobの再生の例(JavaScript)
const videoElement = document.getElementById('playblob');
const videoBlob;

fetch('video.mp4').then(response => {
  return response.blob();
}).then(blob => {
  videoBlob = blob;
  videoElement.addEventListener('loadedmetadata', () => {
    videoElement.play();
  });
  videoElement.src = URL.createObjectURL(videoBlob);
});
```

# ビデオファイルをキャッシュから再生 (Chrome)

Chrome for Android 52では、**Service WorkerとCache APIを使って、キャッシュしたビデオファイルを再生することが可能**になります。(51以前は、`<video>`要素でビデオを再生する際にはService Workerで`fetch`イベントが発生せず、また、キャッシュから再生させることもできません。)

## いくつか、注意事項

* Service Workerをインストール(register)しても、その時点で読み込まれているwebアプリがリソースの読み込み(fetch)を行う時に、**そのままではService Workerでまだ`fetch`イベントが発生しない**点に注意が必要です。`fetch`イベントが発生するのは、次にwebアプリが読み込まれた時以降です。Service Workerのインストール後に直ちにコントロールを有効にして`fetch`イベントが発生できるようにするには、[**`activate`イベントの発生時に`self.clients.claim()`を呼び出します**](http://qiita.com/y_fujieda/items/f9e765ac9d89ba241154#active-状態になったらすぐにコントロールさせたい)。
* `<video>`要素(や`<audio>`要素)でwebサーバ上のコンテンツファイルを再生する場合、webサーバにはHTTPリクエストヘッダの`Range`フィールドによって部分ダウンロードが指定されます。よって、Service Workerでも`fetch`イベントのリスナでは`Range`への対処が必要となる点に注意が必要です(HTTPレスポンスヘッダには`Content-Range`フィールドを含める必要があります)。
* `<video>`要素(や`<audio>`要素)の`src`属性に指定したファイルをService Workerの`install`イベントのリスナでキャッシュしようとすると、**`<video>`(`<audio>`)要素でも先に読み込みを行ってしまう**ため、同じリソースを平行して二重にダウンロードしてしまう点に注意が必要です。これを避けたい場合は、`<video>`(`<audio>`)要素の属性として、`preload="none"`を指定します。

## サンプルコード

`<video>`要素にはMP4ファイルを1つのみ指定していますが、WebM等、複数のコーデックに対応したい場合は、`<video>`要素の`src`属性を指定する代わりに、子要素として`<source>`タグを列挙します。

```html:sample.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Sample</title>
    <script src="init.js"></script>
    <style>video { pointer-events: none; }</style>
  </head>
  <body>
    <video controls preload="none" src="video.mp4"></video>
  </body>
</html>
```

`init.js`ではService Workerのインストール(register)や、インストール直後や2回目以降のロード時のビデオ読み込み処理等を行います。

```js:init.js
// 参考: DOM操作を伴うためDOMContentLoadedイベントのリスナで実行
document.addEventListener('DOMContentLoaded', () => {
  const cacheName = 'video-cache-v1';
  const videoElement = document.querySelector('video');

  function waitUntilInstalled(registration) {
    return new Promise((resolve, reject) => {
      // Service Workerのインストール中の場合(すなわち初回読み込み時)
      if (registration.installing) {
        registration.installing.addEventListener('statechange', event => {
          // Service Workerのinstallイベントのリスナでキャッシュの読み込みが完了した時
          if(event.target.state === 'installed') {
            resolve();
          }
        });
      }
      // Service Workerがインストール済みの場合(すなわち2回目以降の読み込み時)
      else {
        resolve();
      }
    });
  }

  // Service Workerのインストール完了後にビデオ再生を許可
  navigator.serviceWorker.register('serviceworker.js')
    .then(waitUntilInstalled)
    .then(() => {
      videoElement.load();
      videoElement.style.pointerEvents = 'auto';
    });
});
```

Service Worker (`serviceworker.js`)では、インストール時にビデオファイルをキャッシュし、ビデオファイルのfetch発生時に補足してキャッシュを渡す処理を行います。

```js:serviceworker.js
const cacheName = 'video-cache-v1';
const videoFile = 'video.mp4';

self.addEventListener('install', event => {
  // 直ちにactive状態に移行
  self.skipWaiting();

  // Service Workerインストール時、すなわち、
  // 初回読み込み時は、まずビデオファイルをキャッシュ
  event.waitUntil(
    caches.open(cacheName).then(cache => {
      return cache.add(videoFile);
    })
  );
});

self.addEventListener('activate', event => {
  // 直ちにService Workerによるコントロールを有効化
  event.waitUntil(self.clients.claim());
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request.url).then(response => {
      // ビデオファイルのfetch時は、キャッシュを参照
      if (response) {
        const range = event.request.headers.get('range');
        // 部分ダウンロード時
        if (range) {
          const top = parseInt(range.replace(/^(bytes=)(\d+)\-(.*)$/, '$2'));
          return response.arrayBuffer().then(arrayBuffer => {
            return new Response(
              arrayBuffer.slice(top),
              {
                status: 206,
                statusText: 'Partial Content',
                headers: {
                  'Content-Range': 'bytes ' + top + '-' + (arrayBuffer.byteLength - 1) + '/' + arrayBuffer.byteLength
                }
              }
            )
          });
        }
        // 全体ダウンロード時
        else {
          return response;
        }
      }
      // ビデオファイル以外は通常通りwebサーバから読み込み
      else {
        return fetch(event.request.clone());
      }
    })
  );
});
```