Reactで、画面から外れた動画を自動で一時停止する方法

​​React​IntersectionObserver,Playwright,TypeScript,カスタムフック

スクロールして動画が画面から外れたら一時停止するには、<video> 要素を IntersectionObserver で見張り、見えている割合がしきい値を下回ったときに pause() を呼びます。戻ってきたときに続きを再生するのは、フックが止めた動画だけにします。こうすると、ユーザーが一時停止ボタンで止めた動画を、スクロールのたびに勝手に再生してしまうことがありません。

ここでは、そのためのカスタムフック useAutoPause を作りました。あわせて、見えたら必ず play() する書き方がどう困るか、play() の Promise を受けないとどんなエラーが出るかも確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。動画は ffmpeg のテスト用の映像と音で作った 30 秒の WebM です。Firefox と Safari、スマートフォンのブラウザは試していません。

動画をクリックして再生し、下へスクロールすると止まり、上へ戻すと続きから再生される。一時停止してから出し入れすると止めたまま
動画を再生し、画面の外へ出して戻し、一時停止してからもう一度出し入れする5回の操作。video.paused の値が変わった瞬間だけピンクにしている(カーソル、押した瞬間の輪、下の操作の表示と video.paused の表示は GIF 用に重ねたもの)

画面から外れたら止めるフック

要素が画面にどれだけ入っているかは、IntersectionObserver で知ることができます。オプションの threshold に割合を渡すと、見えている割合がその値をまたいだときに、コールバックが呼ばれます(MDN の Thresholds の説明)。コールバックに渡される entry の intersectionRatio が、そのときに見えている割合です。

IntersectionObserver は React の外にあるブラウザの機能なので、useEffect の中で作り、クリーンアップで止めます。公式リファレンスにも、useEffect で IntersectionObserver を使って要素が見えているかを調べる例があります。

ts
import { useEffect, type RefObject } from 'react';

// 動画が画面から外れたら一時停止し、戻ってきたら再生を続ける。
// 続きを再生するのは、このフックが止めた動画だけ(ユーザーが止めた動画は止めたまま)
export function useAutoPause(
  ref: RefObject<HTMLVideoElement | null>,
  threshold = 0.5,
) {
  useEffect(() => {
    const video = ref.current;
    if (!video) return;
    let pausedByObserver = false;

    const observer = new IntersectionObserver(
      ([entry]) => {
        // threshold の割合以上が画面に入っているか
        const visible = entry.intersectionRatio >= threshold;
        if (!visible && !video.paused) {
          video.pause();
          pausedByObserver = true;
        } else if (visible && pausedByObserver) {
          pausedByObserver = false;
          // play() は Promise を返し、拒否されることがある
          video.play().catch(() => {});
        }
      },
      { threshold },
    );
    observer.observe(video);

    // ユーザーが自分で再生し直したら、「フックが止めた」を取り消す
    const onPlay = () => { pausedByObserver = false; };
    video.addEventListener('play', onPlay);

    return () => {
      observer.disconnect();
      video.removeEventListener('play', onPlay);
    };
  }, [ref, threshold]);
}

「フックが止めたかどうか」は pausedByObserver という変数に持たせています。画面に戻ったときに続きを再生するのは、この変数が true のときだけです。画面の表示には使わない値なので、state にはせず、Effect の中の変数にしました。

動画が止まっているかどうかは、video.paused で分かります。フックが止めたあと、画面の外にある間に、別の操作で再生され、また止められることもあります。そのときに「フックが止めた」が残っていると、画面に戻ったときに再生してしまいます。そこで、play イベントが起きたら pausedByObserver を false に戻すようにしました。

使う側では、<video> に付けた ref をフックに渡します。

tsx
import { useRef, type ComponentProps } from 'react';
import { useAutoPause } from './useAutoPause';

export function AutoPauseVideo(props: ComponentProps<'video'>) {
  const ref = useRef<HTMLVideoElement>(null);
  useAutoPause(ref);
  return <video ref={ref} controls playsInline {...props} />;
}
tsx
<AutoPauseVideo src="/sample.webm" width={320} height={180} />

スクロールで出し入れしたときの動き

Playwright で動画をクリックして再生し、スクロールで少しずつ画面の外へ出しました。ログの [ページ] の行は、確かめるために別に付けた IntersectionObserver(しきい値は同じ 0.5)のコールバックが、呼ばれたときに書き出したものです。observe() を呼んだ直後にも1回呼ばれるので、その行は省いています。

text
動画をクリックして再生: 見えている割合 1.00 / 再生中 / 0.6秒
動画の60%が見える位置までスクロール: 見えている割合 0.60 / 再生中 / 1.2秒
  [ページ] isIntersecting=false intersectionRatio=0.40
動画の40%が見える位置までスクロール: 見えている割合 0.40 / 一時停止 / 1.3秒
動画が見えなくなる位置までスクロール: 見えている割合 0.00 / 一時停止 / 1.3秒
1秒待った: 見えている割合 0.00 / 一時停止 / 1.3秒
  [ページ] isIntersecting=true intersectionRatio=1.00
画面に戻した(1秒後): 見えている割合 1.00 / 再生中 / 2.3秒

60% が見えている間は、しきい値の 50% をまたいでいないので、コールバックは呼ばれず、再生が続きました。40% まで出したところでコールバックが呼ばれ、動画が止まりました。画面の外にある間は再生位置が 1.3 秒のまま進まず、画面に戻すと続きから再生されました。

続けて、動画をクリックして自分で一時停止してから、画面の外へ出して戻しました。

text
動画をクリックして一時停止: 見えている割合 1.00 / 一時停止 / 2.3秒
  [ページ] isIntersecting=false intersectionRatio=0.00
  [ページ] isIntersecting=true intersectionRatio=1.00
画面から出して、戻した(1秒後): 見えている割合 1.00 / 一時停止 / 2.3秒

画面に戻ったときもコールバックは呼ばれていますが、フックが止めた動画ではないので、止めたままになりました。

最後に、もう一度再生して画面の外へ出し、フックに止めさせました。そのまま画面の外で、ページのスクリプトから play() と pause() を呼んでから、画面に戻しています。キーボードなどで再生し直して、また止めた場合の代わりです。

text
  [ページ] isIntersecting=false intersectionRatio=0.00
もう一度クリックで再生し、画面の外へ: 見えている割合 0.00 / 一時停止 / 2.5秒
画面の外で play() と pause() を呼んだ: 見えている割合 0.00 / 一時停止 / 3.0秒
  [ページ] isIntersecting=true intersectionRatio=1.00
画面に戻した(1秒後): 見えている割合 1.00 / 一時停止 / 3.0秒

play イベントで pausedByObserver が false に戻っているので、画面に戻しても止めたままでした。

ポイント

MDN の Thresholds の説明には、isIntersecting が true なら、要素の少なくとも一部が重なっていると書かれています。一方、検証環境の Chromium では、しきい値を 0.5 にすると、40% が見えている状態でも isIntersecting は false でした。このフックでは isIntersecting を使わず、intersectionRatio としきい値を比べて判断しています。

見えたら必ず play() する書き方では、止めた動画が動き出す

「見えていれば play()、見えなければ pause()」とだけ書くと、ユーザーが止めた動画まで再生してしまいます。

tsx
const observer = new IntersectionObserver(
  ([entry]) => {
    if (entry.intersectionRatio >= 0.5) video.play();
    else video.pause();
  },
  { threshold: 0.5 },
);

この書き方で、動画を画面に入れ、クリックで一時停止してから、画面の外へ出して戻しました。

text
動画を画面に入れた(クリックはしていない。1秒後): 見えている割合 1.00 / 再生中 / 0.9秒
動画をクリックして一時停止: 見えている割合 1.00 / 一時停止 / 1.0秒
  [ページ] isIntersecting=false intersectionRatio=0.00
  [ページ] isIntersecting=true intersectionRatio=1.00
画面から出して、戻した(1秒後): 見えている割合 1.00 / 再生中 / 2.0秒

自分で止めたはずの動画が、スクロールして戻しただけで再生されました。また、クリックしていないのに、画面に入っただけで再生が始まっています。止めた理由を覚えておかないと、こうした区別ができません。

play() の Promise は catch で受ける

play() は Promise を返し、再生を始められなかったときは reject されます。検証環境の Chromium では、動画の読み込みが終わる前に pause() を呼ぶと、待っていた play() が AbortError で中断されました。

これを確かめるため、Playwright で動画の読み込みを3秒遅らせ、preload="none" の動画を画面に入れて、300ミリ秒後に外へ出しました。見えたら必ず play() する上の書き方では、catch されていないエラー(Uncaught (in promise))が出ました。ログの「捕まえていないエラー」は、Playwright の pageerror イベントで受けたものです。

text
  [捕まえていないエラー] AbortError: The play() request was interrupted by a call to pause(). https://goo.gl/LdLk22
画面に入れて、300ミリ秒後に出した: イベント play → pause / 一時停止

useAutoPause でも同じ流れを試しました。フックが続きを再生するのは自分で止めた動画だけなので、先に再生を始めて画面の外へ出し、フックに止めさせてから、画面に入れて 300ミリ秒後に出しています。

text
再生を始めて、画面から出した: イベント play → pause / 一時停止
画面に入れて、300ミリ秒後に出した: イベント play → pause / 一時停止

画面に入れたときに play イベントが起き、再生が始まる前に pause が起きています。それでも、catch されていないエラーは出ませんでした。

フックの play() が本当に中断されたのかも確かめました。確かめる側で HTMLMediaElement.prototype.play を差し替え、返された Promise の結果を書き出しています(差し替えた関数の中で結果を受けるので、この実行では、catch されていないエラーが出るかどうかは確かめられません)。

text
  [ページ] play() の結果: AbortError
再生を始めて、画面から出した: イベント play → pause / 一時停止
  [ページ] play() の結果: AbortError
画面に入れて、300ミリ秒後に出した: イベント play → pause / 一時停止

1つ目の AbortError は、確かめる側で再生を始めたときの play() が、フックの pause() で中断されたものです。2つ目が、画面に戻ったときにフックが呼んだ play() です。フックの play() も中断されていましたが、前の実行のとおり、catch で受けているので、catch されていないエラーにはなりません。

補足

ブラウザには、音の出る動画の自動再生を止める仕組みがあります。MDN の自動再生のガイドによると、ユーザーがまだページを操作していないと、スクリプトから呼んだ play() は拒否されることがあり、そのときは NotAllowedError で reject されます(play() のページ)。検証環境の Playwright の Chromium では、起動オプションに --autoplay-policy=document-user-activation-required を付けても、クリックの前の play() が成功し、この制限は再現できませんでした。同じガイドには、音を消した(muted の)動画なら自動再生が許されることと、play() が拒否されたときに再生ボタンを出す例が載っています。

​Posted by 86