Reactで、動画のチャプター一覧から好きな場面を再生する方法
動画の下にチャプターの一覧を並べ、押した場面から再生するコンポーネントを 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 などの配信形式は試していません。

押したら currentTime を変えて play() する
チャプターは「開始秒」と「見出し」の組の配列で持ちます。いまの再生位置がどのチャプターに入っているかを返す関数と、秒を「0:12」の形にする関数も用意しました。
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() を呼びます。
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 です。
押した直後のイベント ["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秒まで進んでいました。

チャプターを 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秒まで再生したときの間隔を測りました。
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 の)うちに「組み立てる」を押しました。
押す前 {"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秒から再生が始まりました。
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 要素の説明)。検証に使ったファイルは次のとおりです。
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秒待ったところ、次のようになりました。
{"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 要素のファイルを読み込む手順)。
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 をフックに渡します。一覧のボタンを押したときの処理は、配列で持つ場合と同じです。
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秒まで再生が進んでいます。
一覧 ["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 ファイルに書くかは、管理しやすいほうを選べます。





