メインコンテンツへスキップ

Next.jsで動画再生エラーを撲滅せよ!モバイル(Safari/Chrome)特有の制約と解決策

··2892 文字·6 分
目次

Next.jsのアプリケーションでヒーローエリアの背景動画やループアニメーションを実装する際、PCのブラウザでは完璧に動作し、モバイルの実機でも一見すると正しく動画が再生されているように見えるケースがあります。

しかし、SentryやDatadogといったエラー監視ツールの管理画面を開くと、iOS SafariやChrome mobileなどから DOMException: play() failed because the user didn't interact with the document first. というエラーが数万件規模で検知され、ログが真っ赤に染まっていることがあります。

この記事では、この「モバイル特有の自動再生制限」と「低電力モード」という名の強力なブラウザの制約について、仕様レベルでの原因究明と、プロダクション環境で確実にエラーをハンドリングし、ユーザー体験を守るためのベストプラクティスを解説します。


1. 犯人は「自動再生ポリシー」とiOSの「低電力モード」
#

モバイル環境における動画再生が失敗(PromiseのReject)する背景には、ブラウザベンダーがユーザー体験を守るために敷いている強固なセキュリティポリシーが存在します。

原因①:ブラウザの「自動再生ポリシー(Autoplay Policy)」
#

スマートフォンの通信量(パケット)を勝手に消費したり、公共の場で突然大音量で動画が流れたりするのを防ぐため、ブラウザは「音声ストリームを持つ動画の自動再生」を標準でブロックします。

自動再生を成功させるためには、以下の3つの属性が <video> タグに例外なく設定されている必要があります。

  1. muted (消音): 音声が完全にカットされていること。
  2. playsInline: タップ時に強制的にネイティブの全画面再生プレイヤーに移行せず、Webページ内(インライン)で再生されること。
  3. autoPlay: 読み込み完了後に自動で再生を開始すること。

原因②:iOSの「低電力モード(Low Power Mode)」
#

これが実務上最も厄切な伏兵です。iPhoneのバッテリー残量が低下して「低電力モード」が有効化されると、iOS Safariは**たとえ muted (消音) かつ playsinline であっても、CPUや通信負荷を削減するために自動再生を強制的に停止(ブロック)**します。

この停止状態の動画要素に対して、JavaScriptから video.play() を呼び出すと、ブラウザは確実に Promise を Reject(拒否)し、キャッチされなかったエラー(Uncaught Promise Rejection)としてエラーログに計上されます。


2. プロダクション品質の解決策:play() の Promise を安全にハンドリングする
#

自動再生が拒否されることは「プログラムのバグ」ではなく「ブラウザ側の正当な仕様」です。したがって、エラー監視ツールを汚さないために、play() メソッドが返す Promise の例外処理(Catch)を徹底する必要があります。

❌ 監視ツールを汚す危険な実装例
#

import React, { useEffect, useRef } from 'react';

export const UnsafeVideo: React.FC = () => {
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    // ❌ 戻り値の Promise が Reject された場合に Uncaught エラーとなり、Sentry等のアラートが発火する
    videoRef.current?.play();
  }, []);

  return <video ref={videoRef} src="/hero.mp4" muted playsInline autoPlay />;
};

✅ エラーを安全に処理する堅牢な実装例
#

import React, { useEffect, useRef, useState } from 'react';

export const SafeVideo: React.FC = () => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [isAutoplayPrevented, setIsAutoplayPrevented] = useState<boolean>(false);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    const attemptPlay = async () => {
      try {
        await video.play();
        setIsAutoplayPrevented(false);
      } catch (error) {
        // 低電力モードや自動再生ポリシーでブロックされた場合
        console.warn("Autoplay was prevented by browser security rules:", error);
        
        // 代替UIや、ポスター画像の表示を促すステートフラグをONにする
        setIsAutoplayPrevented(true);
      }
    };

    attemptPlay();
  }, []);

  return (
    <div className="relative w-full h-96 overflow-hidden">
      <video
        ref={videoRef}
        src="/videos/hero.mp4"
        muted
        playsInline
        autoPlay
        loop
        preload="metadata"
        // 自動再生できない場合は、動画の最初のフレームと一致した高画質なポスター画像を表示しておく
        poster="/images/hero-fallback.webp"
        className="w-full h-full object-cover"
      />
      {isAutoplayPrevented && (
        // 低電力モード時のユーザーへの視覚的補助UI(手動再生用再生ボタンのオーバーレイ等)
        <button 
          onClick={() => videoRef.current?.play()}
          className="absolute inset-0 m-auto w-16 h-16 bg-black/60 rounded-full flex items-center justify-center text-white"
        >
          
        </button>
      )}
    </div>
  );
};

3. IntersectionObserver による「見えている時だけ再生」の最適化
#

実務でより品質の高いWebアプリケーションを作るには、画面のスクロール位置を監視し、「ユーザーの視野(ビューポート)に動画が入っているときだけ再生させ、外れたら一時停止する」 という再生最適化が不可欠です。

これにより、モバイル端末の余計なCPU消費を抑え、バッテリー負荷とメモリ占有を大幅に低減できます。

import React, { useEffect, useRef } from 'react';

export const OptimizedVideo: React.FC = () => {
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    // IntersectionObserver を用いて動画要素の交差を検知
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          // ビューポートに入ったら安全に再生を開始
          video.play().catch(() => {
            // 自動再生エラーは静かに握りつぶす
          });
        } else {
          // ビューポートから外れたら即座に一時停止
          video.pause();
        }
      },
      { 
        // 10%以上要素が画面に入ったら検知
        threshold: 0.1 
      }
    );

    observer.observe(video);

    return () => {
      observer.unobserve(video);
      observer.disconnect();
    };
  }, []);

  return (
    <video
      ref={videoRef}
      src="/videos/hero.mp4"
      muted
      playsInline
      loop
      preload="metadata"
      poster="/images/hero-fallback.webp"
      className="w-full"
    />
  );
};

4. モバイル完全適合のためのビデオ要素チェックリスト
#

モバイルブラウザとの完全な互換性を保証するため、Reactコンポーネント上のビデオ定義では以下の項目が完璧に適用されているか確認してください。

  • muted(消音): 大文字小文字の指定ミスがなく、確実に設定されているか?
  • playsInline: playsInline={true} または単一プロップが指定されているか?(Safariで全画面プレイヤーが立ち上がるのを防ぐ)
  • preload="metadata": 動画データ全体を初期ロードでダウンロードし、モバイルの通信量を浪費しない設定になっているか?
  • poster="/...": 低電力モードで自動再生が拒否された場合に、真っ黒な画面にならずに美しいプレースホルダーを表示できているか?
  • play() キャッチ処理: すべてのプログラム再生処理に .catch(() => {}) または try-catch が実装されているか?

5. よくある質問(FAQ)
#

Q. PCブラウザで音付き(mutedなし)の自動再生は一切不可能ですか?
#

A. 基本的に不可能です。ただし、Chrome などの一部のブラウザでは、そのドメインに対してユーザーが過去に活発に操作を行った履歴(メディアエンゲージメントインデックス: MEI)が一定基準を超えている場合のみ、一時的に音声付き自動再生が許可されることがありますが、非常に不安定なため、プロダクションコードでは一律で muted を指定することを強く推奨します。

Q. iOSの低電力モードに入っているか、JavaScriptから事前に検知するAPIはありますか?
#

A. セキュリティおよびトラッキング防止の観点から、ブラウザからユーザーの「低電力モード」の状態に直接アクセスできるAPIは現在提供されていません。そのため、状態を予測して分岐するのではなく、一律で video.play() を実行し、拒否された(catchに入った)イベントをハンドリングして低電力モードを推測・対応するアプローチが唯一の実用解です。

Q. 動画ファイルの読み込み速度自体が遅く、ポスター画像が表示され続けます。
#

A. 動画ファイルのデータ構造(メタデータ)がファイルの末尾に配置されている可能性があります。この場合、ブラウザは動画全体のダウンロードが完了するまで再生を開始できません。エンコード時に -movflags +faststart オプションを付与して、メタデータ(moov atom)をファイルの先頭に再配置するように変換してください。これにより、読み込み完了を待たずにストリーミング再生が可能になります。


著者
ゆーふー
Web開発、インフラ、AI技術に興味があるエンジニアです。日々の学びを記録しています。

関連記事

👤 運営者プロフィール

運営者プロフィール画像

ゆーふー

メガベンチャーで働く現役Webエンジニア(歴約2年)。
フロントエンドからインフラ構築、セキュリティ対策まで、実務で得た「現場のリアルな技術知見」を発信しています。