Safari で音声、動画を再生する際に発生する NotAllowedError に対応する方法

React などでウェブアプリを制作していると、iOS の Safari で音声が再生されないといった問題に直面することがあります。これは iOS の Safari では、音声や動画の JavaScript からのプログラムによる再生を禁止していることに起因します。そのような場合、"NotAllowedError: The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission." という例外が投げられます。ここではこの例外を回避する方法を考えます。

NotAllowedError が投げられる状況とは?

次のような HTML/JavaScript を考えます。

<audio id="sample" preload="auto">
  <source src="./sample.mp3" type="audio/mp3"></source>
</audio>

<script>
function playSound() {
  const audio = document.getElementById("sample");
  audio.play().catch((err) => alert(err));
}
setTimeout(() => {
  playSound();
}, 1000);
</script>

playSound 関数では DOM 要素を取得し、HTMLMediaElementplay メソッドを呼び出しています。play メソッドは Promise を返すため、ここでは例外が投げられた場合にそれをキャッチするために .catch で受け取っています。

音源の再生は setTimeout の部分で行われており、1秒後に(ユーザーのインタラクションなしに)自動的に再生するように指定されています。

このような HTML のページを用意して iOS の Safari で読み込むと、

NotAllowedError: The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.

というメッセージが alert で表示されます。これは MDN

ユーザーエージェントが自動またはスクリプト起動によるメディアの再生を許可しないように設定されている場合、play() を呼び出すと返された promise が直ちに NotAllowedError で拒否されます。 ウェブサイトはこの状況に対処する準備をしておくべきです。 例えば、サイトには再生が自動的に開始されたことを前提としたユーザーインターフェイスを表示するのではなく、返された promise が解決されたか拒否されたかに基づいて UI を更新する必要があります。

と書かれているとおり、「スクリプト起動」によるメディアの再生を許可しないように設定されているからです。

ユーザーのインタラクションによる音源・映像の再生

クリックなどのユーザーのインタラクションを契機とすれば、play メソッドによる音源の再生が可能になります。例えば

<audio id="sample" preload="auto">
  <source src="./sample.mp3" type="audio/mp3"></source>
</audio>
<button id="start">Play Sound</button>

<script>
function playSound() {
  const audio = document.getElementById("sample");
  audio.play().catch((err) => alert(err));
}
document.getElementById("start").addEventListener("click", function() {
  playSound();
});
</script>

のように、ボタンがクリックされたら音源を再生する、というような場合には問題なく再生されます。言い換えれば、クリックのようなユーザーのインタラクションがあって初めて、play による音源の再生が許可されます。

一度クリックして音源再生の許可が下りると、二度目以降のクリックでも問題なく音源が再生されます。注意しておかないといけないのは、この許可は各音源で必要になるということです。例えば次の HTML を考えます。

<audio id="sample1" preload="auto">
  <source src="./sample1.mp3" type="audio/mp3"></source>
</audio>
<audio id="sample2" preload="auto">
  <source src="./sample2.mp3" type="audio/mp3"></source>
</audio>
<button id="start">Play Sound</button>

<script>
function playSound(type) {
  const audio = document.getElementById(`sample${type}`);
  audio.play().catch((err) => alert(err));
}
document.getElementById("start").addEventListener("click", function() {
  playSound(1);
});
setTimeout(() => {
  playSound(2);
}, 5000);
</script>

ここではボタンをクリックすると sample1 という音源を再生し、ページ読み込みの5秒後に sample2 という音源を自動再生する、ということをしようとしています。このとき、ページ読み込んですぐに「Play Sound」ボタンをクリックして音源の再生を許可しても、許可されるのは sample1 という音源に対してのみであって、sample2 については再生が許可されずに NotAllowedError が発生します。これはつまり、どれかの音源をユーザーのインタラクションを契機にして再生さえすればすべての音源の play による再生が許可されるわけではなく、各音源ごとにユーザーのインタラクションによる許可が必要になるということになります。

NotAllowedError を回避するための方法

クリックなどのインタラクションがあれば音源再生の許可が与えられるため、なんでもいいので音源を再生する前のタイミングでユーザーのクリックアクションを拾って、そこで音源再生の許可をとれば良い、ということになります。例えば次の HTML を考えます。

<audio id="sample1" preload="auto">
  <source src="./sample1.mp3" type="audio/mp3"></source>
</audio>
<audio id="sample2" preload="auto">
  <source src="./sample2.mp3" type="audio/mp3"></source>
</audio>
<button id="prepare">Prepare Sound</button>
<button id="start">Play Sound</button>

<script>
function prepareSound(type) {
  const audio = document.getElementById(`sample${type}`);
  audio.play();
  audio.pause();
}
function playSound(type) {
  const audio = document.getElementById(`sample${type}`);
  audio.play().catch((err) => alert(err));
}
document.getElementById("prepare").addEventListener("click", function() {
  prepareSound(1);
  prepareSound(2);
});

setTimeout(() => {
  playSound(2);
}, 5000);
</script>

ここでは「Prepare Sound」ボタンをクリックすると、各音源に対して playpause メソッドを立て続けに呼び出すようにしています。setTimeout のコールバック関数が実行される5秒間の猶予の間にこのボタンをクリックすると、音源再生の許可が与えられて、setTimeout 内からも音源を再生することが可能になります。このように クリックイベントハンドラー内で play & pause を続けて実行することにより、音源を再生することなく音源の再生の許可だけを得ることができます

本筋とはあまり関係ありませんが、prepareSound 関数では playpause メソッドを立て続けに呼び出していますが、play による Promise の解決を待たずに pause メソッドを呼び出しているため、ここでは AbortError が発生します。これは

function prepareSound(type) {
  const audio = document.getElementById(`sample${type}`);
  audio.play().catch(err => alert(err));
  audio.pause();
}

というコードに変更して「Prepare Sound」ボタンをクリックすると、

AbortError: The operation was aborted.

というエラーメッセージがアラートウィンドウに表示されることで確認することができます。

実際のアプリ制作で例えば開始直後から BGM を流したいような場合は、BGM ON/OFF を制御するようなボタンを用意しておいて、ボタンの初期状態は BGM OFF にしておき、クリックによって音源再生の許可を与えて再生する、という実装をすることになります。

またなんらかのタイミングで SE を再生する必要があり、その前のどこかのタイミングでクリックなどの動作が行われるようなアプリなのであれば、そのクリックのところで

audio.play();
audio.pause();

というように(音源を再生せずに)音源再生の許可だけを得ておけば、SE 再生のタイミングで音源を再生することが可能になります。

ユーザーがクリック時に非同期処理する場合でも再生可能

こちらの Qiita の記事 の「パターン4: ユーザーがクリック時に非同期処理(Callback)」で再生開始に失敗するとありますが、これについても

<video width="320" id="id-video" src="sample.mp4"></video>
<button id="id-button-play">Play</button>
<script>
document.getElementById('id-button-play').onclick = function () {
  var video = document.getElementById('id-video');
  video.play().catch(() => {});
  video.pause();
  setTimeout(function() {
    video.play();
  }, 10000);
};
</script>

というようにユーザーのクリック時に play & pause で再生せずに再生の許可だけを得ておけば、setTimeout のような非同期処理内での再生も可能になります。ここでも play による Promise の解決を待たずに pause を呼び出すために AbortError が発生しますが、video.play().catch(() => {})catch によって AbortError を潰しています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です