Reactで、動画のチャプター一覧から好きな場面を再生する方法

​​2026年11月14日​React​Playwright,TypeScript,WebVTT

動画の下にチャプターの一覧を並べ、押した場面から再生するコンポーネントを React で作ります。やることは、押されたら video.currentTime にチャプターの開始秒を入れて play() を呼ぶだけです。あわせて、いま再生中のチャプターを目立たせる方法と、WebVTT のチャプターファイルから一覧を作る方法も紹介します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。動画は ffmpeg で作った24秒の VP9(WebM)で、ブラウザは Playwright で画面の幅を 375px にして開いています。Firefox と Safari、音声のある動画、HLS などの配信形式は試していません。

動画の下に4つのチャプターが並び、押すたびに映像の時刻がそのチャプターの開始秒に変わる様子
チャプターを3回押したときの動き。上の「クリック n回目」、カーソル、ピンクの枠は撮影のために足した表示で、動画の操作部品は撮影のときだけ隠しています

押したら currentTime を変えて play() する

チャプターは「開始秒」と「見出し」の組の配列で持ちます。いまの再生位置がどのチャプターに入っているかを返す関数と、秒を「0:12」の形にする関数も用意しました。

ts
export type Chapter = { start: number; title: string };

// いまの再生位置が入っているチャプターの番号。最初のチャプターより前なら -1
export function chapterIndexAt(chapters: Chapter[], time: number): number {
  let index = -1;
  for (let i = 0; i < chapters.length; i++) {
    if (chapters[i].start <= time) index = i;
    else break;
  }
  return index;
}

// 秒を「1:05」の形にする
export function formatTime(seconds: number): string {
  const s = Math.floor(seconds);
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}

コンポーネントは、video 要素を ref で持ち、チャプターごとに button を並べます。押されたら currentTime に開始秒を入れ、続けて play() を呼びます。

tsx
import { useEffect, useRef, useState } from 'react';
import { type Chapter, chapterIndexAt, formatTime } from './chapters';

type Props = { src: string; chapters: Chapter[]; preload?: 'none' | 'metadata' | 'auto' };

export function VideoChapters({ src, chapters, preload = 'metadata' }: Props) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [time, setTime] = useState(0);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    const update = () => setTime(video.currentTime);
    video.addEventListener('timeupdate', update);
    return () => video.removeEventListener('timeupdate', update);
  }, []);

  const playFrom = (start: number) => {
    const video = videoRef.current;
    if (!video) return;
    video.currentTime = start;
    video.play().catch(() => {
      // 再生が許可されなかったときは、場面の移動だけで止めておく
    });
  };

  const active = chapterIndexAt(chapters, time);

  return (
    <div className="player">
      <video ref={videoRef} src={src} controls playsInline preload={preload} />
      <ol className="chapters">
        {chapters.map((chapter, i) => (
          <li key={chapter.start}>
            <button
              type="button"
              aria-current={i === active ? 'true' : undefined}
              onClick={() => playFrom(chapter.start)}
            >
              <span className="time">{formatTime(chapter.start)}</span>
              <span className="title">{chapter.title}</span>
            </button>
          </li>
        ))}
      </ol>
    </div>
  );
}

MDN の currentTime の説明のとおり、currentTime に値を入れると、動画はその位置へ移動(シーク)します。play() は Promise を返し、再生できなかったときは reject されます(MDN の play() の説明)。ここでは catch で受けて、場面の移動だけで終わらせています。

止まっている動画で「組み立てる(0:12)」を押したところ、video 要素では次の順にイベントが起きました。数字はそのときの currentTime です。

text
押した直後のイベント ["seeking 12.000","play 12.000","timeupdate 12.008","seeked 12.008","playing 12.008"]
playing のあと {"currentTime":12.03,"paused":false,"readyState":4,"active":2}
1秒後 {"currentTime":13.032,"paused":false,"readyState":4,"active":2}

移動が終わる(seeked)のを待たずに play() を呼んでも、12秒の場面から再生が始まっています。1秒後には13秒まで進んでいました。

動画の映像に13秒すぎの時刻が出ていて、下の一覧では「0:12 組み立てる」が水色の枠と太字になっている
「組み立てる」を押して1秒ほどたったところ。再生中のチャプターは aria-current で目立たせています

チャプターを button にしておくと、キーボードでも選べます。検証環境では、「材料をそろえる」のボタンにフォーカスを当てて Enter を押すと6秒から、「できあがり」で Space を押すと18秒から再生が始まりました。

いま再生中のチャプターを、timeupdate で合わせる

一覧のどこを再生しているかは、timeupdate イベントで currentTime を state に入れ、chapterIndexAt で求めています。該当するボタンには aria-current="true" を付け、CSS の [aria-current="true"] で枠と太字を変えました。aria-current は、並んだ要素のうち、いまの項目がどれかを支援技術に伝える属性です(MDN の aria-current の説明)。

timeupdate は、映像の1コマごとに出るわけではありません。MDN の timeupdate の説明では、間隔はシステムの負荷によって変わり、おおよそ 4Hz から 66Hz の間とされています。検証環境の Chromium で、16秒の位置から18.5秒まで再生したときの間隔を測りました。

text
timeupdate の回数(16秒から18.5秒) 9
間隔 ms 最小 265 最大 266 平均 266
「できあがり」に変わったときの currentTime 18.117

間隔は265〜266ミリ秒でした。18秒から始まる「できあがり」に表示が変わったのは、currentTime が18.117秒になったときで、境目から0.1秒ほど遅れて切り替わりました。

チャプターを押したときは、次の timeupdate まで待たずに表示が変わります。移動の途中でも timeupdate が出るので(上のイベントの順番を参照)、押したボタンにすぐ aria-current が移ります。冒頭の GIF では、aria-current が移ったボタンを、撮影用にピンクの枠で示しています。

動画を読み込む前に押しても、その場面から始まる

video 要素の preload="none" は、動画を前もって読み込まないようにブラウザに伝える指定です(MDN の video 要素の説明)。この指定で、まだ何も読み込んでいない(readyState が 0 の)うちに「組み立てる」を押しました。

text
押す前 {"currentTime":0,"paused":true,"readyState":0,"active":0}
押して1.5秒後 {"currentTime":13.49,"paused":false,"readyState":4,"active":2}

1.5秒後には13.49秒まで進んでいて、12秒から再生が始まったことが分かります。MDN の currentTime の説明にも、再生前に入れた値は、play() を呼んだときに再生を始める位置になると書かれています。読み込みの完了を待ってから currentTime を入れる、といった処理は要りませんでした。

Range に応えないサーバーでは、0秒に戻される

動画ファイルを配信するサーバーが Range 要求(ファイルの一部だけを求める要求)に応えない場合を試しました。検証用のサーバーで、Range ヘッダーを無視して、いつもファイル全体を 200 で返すようにしています。この動画で「組み立てる」を押すと、12秒には移動せず、0秒から再生が始まりました。

text
seekable [[0,0]]
押して1.5秒後 {"currentTime":1.506,"paused":false,"readyState":4,"active":0}
さらに3秒後の seekable [[0,0]] buffered [[0,24]]
全体を読み込んだあとにもう一度押して1秒後 {"currentTime":1.002,"paused":false,"readyState":4,"active":0}
(比べるため)Range に応えるサーバーの seekable [[0,24]]

seekable は、ブラウザが移動できる時間の範囲です(MDN の seekable の説明)。Range に応えないサーバーでは、0秒から0秒まで、つまり移動できる範囲がありませんでした。24秒すべてを読み込み終えた(buffered が0〜24秒になった)あとでも変わらず、もう一度押しても0秒に戻りました。HTML の仕様のシークの手順では、移動先が seekable の範囲の外なら、範囲の中でいちばん近い位置へ移動すると決められています。ここでは、その位置が0秒でした。

ポイント

チャプターを押しても最初から再生されるときは、動画を配信しているサーバーが Range 要求に応えているかを確かめてください。応えているサーバーは、応答に Accept-Ranges: bytes を付け、一部だけを求められたら 206 Partial Content を返します(MDN の HTTP の範囲リクエストの説明)。ブラウザでは、video.seekable の範囲が動画の長さまであるかで見分けられます。

WebVTT のチャプターファイルから一覧を作る

チャプターを、字幕と同じ WebVTT の形式のファイルで持つこともできます。track 要素の kind を chapters にすると、そのファイルはチャプターの見出しとして扱われます(MDN の track 要素の説明)。検証に使ったファイルは次のとおりです。

text
WEBVTT

00:00:00.000 --> 00:00:06.000
オープニング

00:00:06.000 --> 00:00:12.000
材料をそろえる

00:00:12.000 --> 00:00:18.000
組み立てる

00:00:18.000 --> 00:00:24.000
できあがり

ただし、track 要素を置いただけではファイルは読み込まれません。MDN の TextTrack の mode の説明では、mode の初期値は disabled で、hidden か showing に変えるまでファイルを読み込まないとされています。default 属性を付けたときの初期値は showing です。検証環境でも、video 要素に track 要素を足して1秒待ったところ、次のようになりました。

text
{"attrs":"(なし)","mode":"disabled","readyState":0,"cues":null}
{"attrs":"default","mode":"showing","readyState":2,"cues":4}

default を付けない track 要素は、cues(見出しの一覧)が null のままでした。そこで、フックの中で mode を hidden に変えてから、読み込みが終わるのを待ちます。読み込みが終わると、track 要素に load イベントが届きます(HTML の仕様の、track 要素のファイルを読み込む手順)。

ts
import { type RefObject, useEffect, useState } from 'react';
import type { Chapter } from './chapters';

// <track kind="chapters"> の WebVTT を読み、チャプターの配列にする
export function useChapterTrack(trackRef: RefObject<HTMLTrackElement | null>): Chapter[] {
  const [chapters, setChapters] = useState<Chapter[]>([]);

  useEffect(() => {
    const el = trackRef.current;
    if (!el) return;
    const track = el.track;
    // disabled のままだと、ブラウザはファイルを読み込まない
    if (track.mode === 'disabled') track.mode = 'hidden';

    const read = () => {
      setChapters(
        Array.from(track.cues ?? [], (cue) => ({
          start: cue.startTime,
          title: (cue as VTTCue).text,
        })),
      );
    };
    if (el.readyState === HTMLTrackElement.LOADED) read();
    el.addEventListener('load', read);
    return () => el.removeEventListener('load', read);
  }, [trackRef]);

  return chapters;
}

track 要素の readyState が LOADED(2)なら、読み込みはもう終わっています(MDN の HTMLTrackElement の readyState の説明)。そのときは load を待たずにすぐ読みます。TypeScript では cues の要素が TextTrackCue の型になっていて text がないので、WebVTT の見出しを表す VTTCue として扱いました。

使う側では、video 要素の中に track 要素を置き、その ref をフックに渡します。一覧のボタンを押したときの処理は、配列で持つ場合と同じです。

tsx
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLTrackElement>(null);
const chapters = useChapterTrack(trackRef);

// ...

<video ref={videoRef} src={src} controls playsInline preload="metadata">
  <track ref={trackRef} kind="chapters" src={vtt} srcLang="ja" label="チャプター" />
</video>

このフックを使ったページでは、一覧に4つのチャプターが並び、track の mode は hidden、readyState は 2 になりました。「できあがり」を押して0.8秒後には、18.801秒まで再生が進んでいます。

text
一覧 ["0:00オープニング","0:06材料をそろえる","0:12組み立てる","0:18できあがり"]
track {"mode":"hidden","readyState":2}
「できあがり」を押して0.8秒後 {"currentTime":18.801,"paused":false}

mode の hidden は、見出しを読み込むものの表示はしない状態です(MDN の TextTrack の mode の説明)。押したときの処理は配列のときと同じなので、チャプターをコードに書くか、動画の横に置いた .vtt ファイルに書くかは、管理しやすいほうを選べます。

​Posted by 86