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

  • 74
    いいね
  • 0
    コメント

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

ここ最近になって、この辺りのユーザ体験(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」で紹介されていますので、詳細は当該記事をご覧下さい。(本記事も当該記事を参考にしています。)

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

Safari for iOS 10Chrome for Android 53では、「音声を鳴らさないという条件で」ページ読み込み後のビデオの自動再生が可能となります。

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

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

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

実際の挙動としては、Chrome、Safariともに、スクロール等で画面から見える状態になった時に自動再生が開始されます。さらに、Safariの場合は、再生中にスクロールで見えない状態になると、自動的に再生を一時停止します。(Chromeでは、今のところ再生が継続されてしまいますが、今後変更があるかどうかは今のところ不明です。)

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

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

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

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

Safari for iOS 10では、ようやくiPhone等でもビデオのブラウザ内インライン再生が可能になります。具体的な方法ですが、<video>要素にwebkit-playsinline属性を記述します。

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

Blobの再生 (Chrome)

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

Blobの再生の例(HTML)
<video id="playblob"></video>
Blobの再生の例(JavaScript)
let videoElement = document.getElementById('playblob');
let 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()を呼び出します
  • <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>タグを列挙します。

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回目以降のロード時のビデオ読み込み処理等を行います。

init.js
// 参考: DOM操作を伴うためloadイベントのリスナで実行
window.addEventListener('load', () => {
  let cacheName = 'video-cache-v1';
  let 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発生時に補足してキャッシュを渡す処理を行います。

serviceworker.js
let cacheName = 'video-cache-v1';
let 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) {
        let range = event.request.headers.get('range');
        // 部分ダウンロード時
        if(range) {
          let 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());
      }
    })
  );
});