動画プレーヤーをReactで自作: 字幕・速度・視聴計測まで実装
講座やメディア向けの動画プレーヤーをClaude Codeで自作。再生制御・字幕・アクセシビリティ・視聴計測まで、コピペで動くReactコード付きで解説します。
有料講座の動画を埋め込んだ翌日、受講者から1通だけメールが来ました。「1.25倍で見たいんですけど、できますか?」
そのとき僕が使っていたのは、見栄えのいい動画プレーヤーライブラリでした。速度変更ボタンはある。でも字幕の位置がスマホで崩れ、再開位置が保存されず、視聴の途中離脱がどこで起きているのか何も分からない。要するに、再生はできるけど「講座として使えない」状態だったんです。
結局そのライブラリは捨てて、ネイティブの<video>要素から自分で組み直しました。今日はその実装を、コピペで動くReactコード込みで全部書きます。
この記事の要点
- 動画プレーヤーは「再生ボタン」ではなく、再開位置・字幕・速度・視聴計測まで含めた体験。ここが完走率と売上を左右する。
- 土台はネイティブの
<video>要素。HTMLMediaElementのcurrentTimeやpausedをReactに同期するのが安定実装の核心。 - Reactの状態はボタン押下時の推測で変えず、
play/pauseイベントから同期する。自動再生制限でvideo.play()が失敗するためtry/catchが必須。 - アクセシビリティは「ネイティブ操作を隠した分、同等の操作性を戻す」だけ。本物の
button、input type="range"、role="alert"で足りる。 - 視聴計測は毎秒送らない。再生開始・25/50/75%・完了・エラー・CTAクリックの7点だけで改善に使える。
動画プレーヤーは「再生ボタン」ではない
動画プレーヤーとは、動画ファイルやストリームを読み込んで、再生・一時停止・シーク・音量・字幕・速度変更を読者が操作できるUIのこと。Webではたいてい、土台にネイティブの<video>要素を置き、JavaScriptからHTMLMediaElementのcurrentTime、duration、paused、volume、muted、playbackRateを読んだり書いたりします。
定義を先に置くのには理由があります。動画プレーヤーは装飾じゃないからです。
学習プロダクトなら、受講者が途中から再開できるか、字幕で理解を補えるか、話速を落として復習できるか——これが完走率に直結します。冒頭のメールがまさにそれでした。メディアサイトなら、記事の初期表示を邪魔しないか、モバイル回線でも待たせないか、見終わった読者が次の記事や会員登録に自然に進むか。SaaSの製品デモなら、再生体験そのものが信頼感になります。
実装の前に、MDNの<video>要素リファレンスとHTMLMediaElement APIに一度目を通しておくと、後で「このプロパティ何だっけ」が減ります。Claude Codeはコンポーネント、テスト、アクセシビリティレビュー、計測イベントをまとめて整えるのが得意です。ただし「どの体験を守るか」だけはプロダクト側の判断で、AIには丸投げできません。
ネイティブ・カスタム・ストリーミングをどう選ぶか
最初に決めるのはボタンの見た目じゃなくて、再生方式です。ここを飛ばすと、後から土台ごと作り直すはめになります(僕がそうでした)。
ネイティブの<video controls>は最速で公開できて、ブラウザがキーボード操作や基本的な字幕表示を面倒見てくれます。一方、進捗保存・会員向けCTA・章立て・独自計測が要るなら、HTMLMediaElementを直接操作するカスタムコントロール。動画が長い、視聴者が多い、国や回線差が大きいなら、HLSやDASH、動画配信サービスによるストリーミングも視野に入ります。
| 方式 | 向いている場面 | 本番で見るポイント |
|---|---|---|
ネイティブ<video controls> | 記事内の短い埋め込み、社内ドキュメント、簡単なLP | 実装が速い。ブランド表現や詳細な計測は限定されるが、基本操作の信頼性は高い |
HTMLMediaElement上のカスタム操作 | 学習プロダクト、メディア、SaaSデモ、会員向け動画 | UI・再開位置・CTA表示・分析を制御できる。代わりにアクセシビリティとエラー処理の責任を負う |
| HLS/DASHや配信基盤 | 長尺講座、大量カタログ、ライブ、保護が必要な動画 | 変換・マニフェスト・CDN・ビットレート・認可・再生ライブラリの設計が必要 |
Claude Codeで最初の一本を作るなら、短いMP4・preload="metadata"・poster画像・字幕ファイルから始めるのが現実的です。章リンク、字幕検索、完了率、購入者向け表示、社内SSO連携が必要になったらカスタム化し、「1本のMP4じゃ無理だ」と分かった段階でストリーミングへ移る。この順番を守ると、最初から過剰な基盤を抱え込まずに済みます。
レイヤーで分けると、依頼が具体的になる
動画UIをひとかたまりで考えると、修正が毎回大ごとになります。レイヤーで割っておくと、Claude Codeへの依頼を小さく切れる。
| レイヤー | 役割 | Claude Codeに確認させること |
|---|---|---|
| アセット管理 | MP4/WebM、poster、字幕、マニフェストを用意 | URL、MIME type、CORS、期限付きURL、代替テキスト |
| メディア要素 | 再生・読み込み・時間・字幕・エラーをブラウザに任せる | preload、playsInline、track、fallback文言、イベント購読 |
| 状態管理 | currentTime・paused・muted・速度をReactに反映 | 推測で状態を変えず、media eventから同期しているか |
| 操作UI | 再生・シーク・音量・速度・字幕・全画面を提供 | buttonやinputを使い、キーボード操作とラベルを保つ |
| 継続状態 | 再開位置・完了・速度・ミュート設定を保存 | 保存範囲を最小化し、公開サイトはプライバシー説明を用意 |
| 計測 | 再生開始、25/50/75%、完了、エラー、CTAクリック | 毎秒送らず、改善に使う指標だけ残す |
| パフォーマンス | poster、CDN、lazy loading、ビットレート | CLS、初期転送量、モバイル回線、キャッシュヘッダー |
この分割があると、依頼が「シークバーのアクセシビリティだけレビューして」「posterのサイズと読み込み戦略だけ直して」「完了イベントを二重送信しないようテストして」と具体化します。動画UIの小さな修正が、配信基盤や分析設計まで巻き込んで事故るのを防げるわけです。
効く場面を4つ
1. 有料講座のレッスン 受講者は途中で離席し、別端末で戻り、1.25倍速で復習します。要るのはきれいなアニメーションより、字幕・再開位置・完了条件・次レッスンへの導線。完了イベントはページ表示ではなく、一定割合を視聴したタイミングで送ります。
2. メディア記事の埋め込み 読者はテキストを読みながら「動画を見るか」を判断します。poster画像と文字起こしを近くに置き、動画本体は必要になるまで重く読み込ませない。見終えた読者には関連記事、ニュースレター、会員登録、有料レポートへの導線を出します。
3. SaaSの製品デモ 機能別チャプター、料金ページ、APIドキュメント、問い合わせボタンを視聴位置に合わせて変えると、ただの動画が「商談前の説明UI」になります。視聴完了よりも、どの章を見てどのCTAを押したかが大事。
4. 社内研修とサポート オンボーディングやコンプライアンス、問い合わせ対応の例を動画化するなら、SSO配下での安定再生・字幕・監査ログ・エラー時の案内が効きます。派手さより「誰がどこまで見たか」を必要最小限で確認できることが価値です。
コピペで動くReact/TypeScriptコード
ここからが本体です。Vite、Next.js、AstroのReact islandにそのまま置けます。外部プレーヤーライブラリには依存せず、ネイティブの<video>とHTMLMediaElementイベントだけで動きます。
ポイントは1か所だけ覚えてください。ReactのisPlayingを、ボタンを押した瞬間に書き換えていないこと。play/pauseイベントを購読して、ブラウザの実際の状態から同期しています。冒頭で僕が踏んだ「ボタンは反応するのに再生されない」バグは、ここが原因でした。
import { useEffect, useRef, useState, type ChangeEvent } from "react";
// 字幕トラック1本ぶんの定義
type CaptionTrack = {
src: string;
srcLang: string;
label: string;
default?: boolean;
};
type ProductionVideoPlayerProps = {
src: string;
title: string;
poster?: string;
captions?: CaptionTrack[];
};
// 秒数を 1:05 のような表示に整える
function formatTime(value: number) {
if (!Number.isFinite(value)) return "0:00";
const minutes = Math.floor(value / 60);
const seconds = Math.floor(value % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
}
export function ProductionVideoPlayer({
src,
title,
poster,
captions = [],
}: ProductionVideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(0.8);
const [rate, setRate] = useState(1);
const [error, setError] = useState("");
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// ブラウザの実際の状態を React に映す(推測で動かさない)
const syncTime = () => setCurrentTime(video.currentTime);
const syncDuration = () => {
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
};
const syncPlayState = () => setIsPlaying(!video.paused);
const syncVolume = () => setVolume(video.muted ? 0 : video.volume);
video.addEventListener("timeupdate", syncTime);
video.addEventListener("loadedmetadata", syncDuration);
video.addEventListener("durationchange", syncDuration);
video.addEventListener("play", syncPlayState);
video.addEventListener("pause", syncPlayState);
video.addEventListener("volumechange", syncVolume);
return () => {
video.removeEventListener("timeupdate", syncTime);
video.removeEventListener("loadedmetadata", syncDuration);
video.removeEventListener("durationchange", syncDuration);
video.removeEventListener("play", syncPlayState);
video.removeEventListener("pause", syncPlayState);
video.removeEventListener("volumechange", syncVolume);
};
}, []);
async function togglePlay() {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
try {
// 自動再生制限で失敗することがあるので必ず try/catch
await video.play();
setError("");
} catch {
setError("再生がブロックされました。もう一度再生を押すか、ブラウザ設定を確認してください。");
}
} else {
video.pause();
}
}
function seek(event: ChangeEvent<HTMLInputElement>) {
const video = videoRef.current;
if (!video) return;
const nextTime = Number(event.target.value);
video.currentTime = nextTime;
setCurrentTime(nextTime);
}
function changeVolume(event: ChangeEvent<HTMLInputElement>) {
const video = videoRef.current;
if (!video) return;
const nextVolume = Number(event.target.value);
video.volume = nextVolume;
video.muted = nextVolume === 0;
setVolume(nextVolume);
}
function changeRate(event: ChangeEvent<HTMLSelectElement>) {
const video = videoRef.current;
if (!video) return;
const nextRate = Number(event.target.value);
video.playbackRate = nextRate;
setRate(nextRate);
}
function toggleMute() {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
}
return (
<section className="production-video-player" aria-label={`${title} 動画プレーヤー`}>
<video ref={videoRef} poster={poster} preload="metadata" playsInline>
<source src={src} type={src.endsWith(".webm") ? "video/webm" : "video/mp4"} />
{captions.map((track) => (
<track
key={track.src}
kind="captions"
src={track.src}
srcLang={track.srcLang}
label={track.label}
default={track.default}
/>
))}
お使いのブラウザは動画要素に対応していません。
</video>
<div role="group" aria-label="動画コントロール">
<button type="button" onClick={togglePlay} aria-label={isPlaying ? "一時停止" : "再生"}>
{isPlaying ? "一時停止" : "再生"}
</button>
<label>
<span>シーク</span>
<input
type="range"
min="0"
max={duration || 0}
step="0.1"
value={duration ? currentTime : 0}
onChange={seek}
aria-valuetext={`${formatTime(currentTime)} / ${formatTime(duration)}`}
/>
</label>
<output>
{formatTime(currentTime)} / {formatTime(duration)}
</output>
<button type="button" onClick={toggleMute} aria-label={volume === 0 ? "ミュート解除" : "ミュート"}>
{volume === 0 ? "ミュート解除" : "ミュート"}
</button>
<label>
<span>音量</span>
<input type="range" min="0" max="1" step="0.05" value={volume} onChange={changeVolume} />
</label>
<label>
<span>速度</span>
<select value={rate} onChange={changeRate}>
{[0.75, 1, 1.25, 1.5, 2].map((speed) => (
<option key={speed} value={speed}>
{speed}x
</option>
))}
</select>
</label>
</div>
{error ? <p role="alert">{error}</p> : null}
</section>
);
}
このコンポーネントだけで、冒頭メールの「1.25倍で見たい」には<select>の速度変更で答えられます。再開位置の保存は、timeupdateでcurrentTimeをlocalStorageに間引き保存し、loadedmetadataの後に書き戻す数行を足すだけ。最初から全部盛りにせず、ここを土台に必要な機能を一つずつ載せていくのがおすすめです。
視聴計測は7点だけでいい
ここで深掘りしたいのが計測です。動画UIで一番もったいないのが、毎秒イベントを送ってログを溢れさせて、結局どこで離脱したか分からないパターン。僕が最初にやった失敗そのものです。
送るべきは7点だけ。再生開始・25%・50%・75%・完了・エラー・CTAクリック。これをtimeupdateの中で「しきい値を超えた瞬間に一度だけ」送ります。
// 進捗イベントを1回ずつだけ送る最小実装(前掲コンポーネントの useEffect 内に追加)
const sent = new Set<string>();
const track = (name: string) => {
if (sent.has(name)) return; // 同じイベントの二重送信を防ぐ
sent.add(name);
// 実際の送信先(GA4 / 自前API など)に差し替える
window.dispatchEvent(new CustomEvent("video-metric", { detail: { name, src } }));
};
const onTime = () => {
if (!video.duration) return;
const ratio = video.currentTime / video.duration;
if (ratio >= 0.25) track("p25");
if (ratio >= 0.5) track("p50");
if (ratio >= 0.75) track("p75");
};
video.addEventListener("play", () => track("start"), { once: true });
video.addEventListener("timeupdate", onTime);
video.addEventListener("ended", () => track("complete"));
video.addEventListener("error", () => track("error"));
Setで送信済みを管理するのが地味な肝です。timeupdateは毎秒数回発火するので、これがないとp50を何十回も送ってしまう。完了はendedイベントで取り、ページ表示では取らない。これだけで、学習サービスなら「完了率が低いレッスンはどれか」、メディアなら「視聴後に会員登録へ進んだか」が見えます。
計測の観点をもう少し詰めたいときは、Claude Codeでのテスト戦略で「二重送信しないか」をテストに落とす方法が参考になります。
アクセシビリティとパフォーマンスの落とし穴
最大の落とし穴は、ネイティブ操作を隠したのに同等の操作性を戻していない状態です。再生ボタンをdivで作る、シークバーにラベルがない、キーボードで速度を変えられない、字幕がない——見た目が良くても、これでは講座に使えません。ボタンは本物のbutton、シークはinput type="range"、エラーはrole="alert"で伝える。基本はこれだけです。キーボード操作と字幕の詳しい観点はClaude Codeでアクセシビリティを担保するにまとめてあります。
パフォーマンス面で危ないのは、記事一覧やLPで複数の動画を同時に読み込む設計です。poster画像に幅と高さを持たせ、任意視聴の動画はpreload="metadata"にし、本体はCDNから配信する。長尺講座を1本の巨大MP4で配ると、モバイル回線・海外視聴・途中離脱のすべてで不利になります。初期表示の重さが気になるならClaude Codeでパフォーマンスを最適化するも合わせて。音だけを扱うケースはClaude Codeで音声プレーヤーを作るが近い構成です。
公開前のチェックは、この8項目を僕は毎回見ています。
- キーボード、タッチ、マウス、スクリーンリーダーのラベルを確認した
- 字幕か文字起こしを用意し、重要情報を動画だけに閉じ込めていない
- モバイルで
playsInlineが効き、意図しない全画面遷移が起きない - poster画像の比率とサイズを固定し、CLSを起こしていない
- 初期表示に不要な動画へ
preload="auto"を付けていない - URL期限切れ、字幕欠落、低速回線、autoplayブロックをテストした
- 再生開始・進捗・完了・エラー・CTAクリックの計測名を決めた
- カスタム操作が壊れたときにネイティブ
controlsへ戻す手順を用意した
よくある質問
Q. 動画プレーヤーは自作とライブラリ、どちらがいい?
記事内の短い埋め込みなら<video controls>で十分。進捗保存・CTA・章立て・独自計測が要るなら自作(上のコンポーネント)が向きます。長尺・大量カタログ・ライブはストリーミング前提のライブラリへ。要件で切り分けるのが正解です。
Q. video.play()が再生されないのはなぜ?
ブラウザの自動再生制限です。ユーザー操作なしの再生や、音ありの自動再生はブロックされます。await video.play()を必ずtry/catchで包み、失敗したら再操作を促すか、mutedを付けて再試行してください。
Q. 再開位置(途中から再生)はどう実装する?
timeupdateでcurrentTimeを間引いてlocalStorageに保存し、loadedmetadataの後にvideo.currentTimeへ書き戻すだけです。公開サイトでは何を保存しているか一言プライバシー説明を添えます。
Q. 視聴計測はどのタイミングで送る?
毎秒は送りません。再生開始・25/50/75%・完了・エラー・CTAクリックの7点を、Setで二重送信を防ぎつつ一度ずつ。完了はendedイベントで取り、ページ表示では取らないのがコツです。
Q. Claude Codeに動画プレーヤーを作らせるコツは? レイヤー表のように依頼を小さく切ること。「シークバーのアクセシビリティだけ」「completeイベントの二重送信テストだけ」と渡すと、修正が配信基盤まで巻き込まずに済みます。レビュー時は「media eventから状態同期しているか」「キーボードで全操作できるか」「初期ロードで本体を落としていないか」の3点を先に見せると速いです。
実際に試した結果
冒頭の「1.25倍で見たい」メール以来、僕は動画プレーヤーを「ライブラリ選び」の問題だと思うのをやめました。見るのは毎回この3点です。Reactの状態をplay/pauseイベントから同期しているか。video.play()をtry/catchで守っているか。計測を7点に絞れているか。
ライブラリを捨ててネイティブの<video>から組み直したら、字幕の崩れは消え、再開位置は数行で実装でき、Setで二重送信を止めた計測のおかげで「どのレッスンで離脱が多いか」が初めて見えました。完了率の低かった1本は、実は冒頭90秒が長すぎただけで、そこを切ったら完走が伸びた。賢い動画ライブラリを探すより、ネイティブ要素に必要な分だけ足す。遠回りに見えて、これがいちばん速いというのが今の実感です。
この動画UIを自社の教材・社内研修・メディアに合わせて設計したい人は、研修・相談ページからどうぞ。実装レビューや計測設計から一緒に詰められます。手元で先に試したい人は教材一覧もあります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。