Edited at

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

More than 1 year has passed since last update.

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

ここ最近になって、この辺りのユーザ体験(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 for Androidでは58以降で対応しています。)

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

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


スクリプトから再生

const video = document.querySelector('video');

const play = video.play();
if (play instanceof Promise) {
play.catch(error => {
console.error('自動再生できません');
});
}


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

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


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

<video src="video-for-inline-playback.mp4" playsinline></video>


特に、iOS 11でMediaStreamのビデオトラックの表示(getUserMediaやWebRTC等)に<video>要素を使う場合、iPhoneではplaysinline属性がないと表示できないので、注意が必要です。


Blobの再生 (Chrome)

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


Blobの再生の例(HTML)

<video id="playblob"></video>



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()を呼び出します


  • <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操作を伴うため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発生時に補足してキャッシュを渡す処理を行います。


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());
}
})
);
});