Claude Code動画プレーヤー実装: 学習・メディア向け本番UI
Reactで使える動画プレーヤーをClaude Codeで作る。アクセシビリティ、配信方式、収益導線まで整理。
2026年版アップグレード: 動画プレーヤーとは何か
動画プレーヤーとは、動画ファイルやストリームを読み込み、再生・一時停止・シーク・音量・字幕・速度変更などを読者が操作できるようにするUIです。Webでは多くの場合、土台にネイティブの<video>要素を置き、JavaScriptからHTMLMediaElementのcurrentTime、duration、paused、volume、muted、playbackRateを読んだり更新したりします。
この定義を最初に置く理由は、動画プレーヤーが単なる装飾ではないからです。学習プロダクトでは、受講者が途中から再開できるか、字幕で理解を補えるか、話速を落として復習できるかが完走率に直結します。メディアサイトでは、記事の初期表示を邪魔しないか、モバイル回線でも待たせないか、動画視聴後に次の記事や会員登録へ自然につながるかが重要です。SaaSの製品デモでは、再生体験そのものが信頼感になります。
実装時は、MDNの<video>要素リファレンスとHTMLMediaElement APIを確認してください。Claude Codeはコンポーネント、テスト、アクセシビリティレビュー、計測イベントをまとめて整えるのに向いています。ただし「どの体験を守るか」はプロダクト側の判断です。まずは読者に必要な最小のプレーヤーから始めます。
関連実装として、音だけを扱う場合はClaude Code音声プレーヤー、キーボード操作や字幕の観点はClaude Codeアクセシビリティ、読み込み速度はClaude Codeパフォーマンス最適化も合わせて見ると設計がぶれにくくなります。教材やメディア導線を売上につなげる場合は、研修・相談ページをCTAとして置き、実装レビューや運用設計に進めます。
ネイティブ<video>、カスタム操作、ストリーミングの違い
最初に決めるべきなのはボタンの見た目ではなく、どの再生方式を採用するかです。ネイティブの<video controls>は最速で公開でき、ブラウザがキーボード操作や基本的な字幕表示を面倒見てくれます。一方、学習管理、進捗保存、会員向けCTA、章立て、独自の計測が必要なら、HTMLMediaElementを操作するカスタムコントロールが向いています。動画が長い、視聴者が多い、国や回線差が大きい場合は、HLSやDASH、または動画配信サービスによるストリーミングも検討します。
| 方式 | 向いている場面 | 本番で見るポイント |
|---|---|---|
ネイティブ<video controls> | 記事内の短い埋め込み、社内ドキュメント、簡単なLP | 実装が速い。ブランド表現や詳細な計測は限定されるが、基本操作の信頼性は高い。 |
HTMLMediaElement上のカスタム操作 | 学習プロダクト、メディアサイト、SaaSデモ、会員向け動画 | UI、再開位置、CTA表示、分析を制御できる。代わりにアクセシビリティとエラー処理の責任も持つ。 |
| HLS/DASHや動画配信基盤 | 長尺講座、大量カタログ、ライブ、保護が必要な動画 | 変換、マニフェスト、CDN、ビットレート、認可、再生ライブラリの設計が必要になる。 |
Claude Codeで最初の実装を作るなら、短いMP4、preload="metadata"、poster画像、字幕ファイルから始めるのが現実的です。章リンク、字幕検索、完了率、Gumroad購入者向け表示、社内SSO連携などが必要になったらカスタム化し、1本のMP4では厳しいと分かった段階でストリーミングへ移ります。
アーキテクチャ表
| レイヤー | 役割 | Claude Codeに確認させること |
|---|---|---|
| アセット管理 | MP4/WebM、poster画像、字幕、ストリーミングマニフェストを用意する | URL、MIME type、CORS、期限付きURL、代替テキストを確認する。 |
| ネイティブメディア要素 | 再生、読み込み、時間、字幕、エラーをブラウザに任せる | preload、playsInline、track、fallback文言、イベント購読を確認する。 |
| 状態管理 | currentTime、duration、paused、muted、volume、速度をReactに反映する | 推測で状態を変えず、media eventから同期しているかを見る。 |
| 操作UI | 再生ボタン、シークバー、音量、速度、字幕、全画面を提供する | buttonやinputを使い、キーボード操作とラベルを保つ。 |
| 継続状態 | 再開位置、完了状態、速度、ミュート設定を保存する | 保存範囲を最小化し、公開サイトではプライバシー説明を用意する。 |
| 計測 | 再生開始、25/50/75%、完了、エラー、CTAクリックを見る | 毎秒イベントを送らず、改善に使う指標だけ残す。 |
| パフォーマンス | poster、CDN、lazy loading、ビットレートを調整する | CLS、初期転送量、モバイル回線、キャッシュヘッダーを確認する。 |
この分割にしておくと、Claude Codeへの依頼も具体的になります。「シークバーのアクセシビリティだけレビューして」「posterのサイズと読み込み戦略だけ直して」「完了イベントを二重送信しないようテストして」と小さく切れるため、動画UIの修正が配信基盤や分析設計まで巻き込む事故を防げます。
実際のユースケース
ユースケース1は有料講座のレッスンです。受講者は途中で離席し、別端末で戻り、1.25倍速で復習します。必要なのはきれいなアニメーションより、字幕、再開位置、完了条件、次のレッスンへの導線です。完了イベントはページ表示ではなく、一定割合を視聴したタイミングで送ります。
ユースケース2はメディア記事の埋め込み動画です。読者はテキストを読みながら動画を見るか判断します。poster画像と文字起こしを近くに置き、動画本体は必要になるまで重く読み込ませません。動画を見終えた読者には、関連記事、ニュースレター、会員登録、または有料レポートへの導線を出します。
ユースケース3はSaaSの製品デモです。機能別チャプター、料金ページへの導線、APIドキュメントへのリンク、問い合わせボタンを視聴位置に合わせて変えると、単なる動画が商談前の説明UIになります。視聴完了よりも、どの章を見てどのCTAを押したかが重要です。
ユースケース4は社内研修とサポートです。営業トーク、オンボーディング、コンプライアンス、問い合わせ対応の例を動画化する場合、SSO配下での安定再生、字幕、監査ログ、エラー時の案内が重要です。派手な見た目より、誰がどこまで見たかを必要最小限で確認できることが価値になります。
コピペで動くReact/TypeScriptコード
次のコンポーネントは、Vite、Next.js、AstroのReact islandなどにそのまま置ける最小構成です。外部プレーヤーライブラリに依存せず、ネイティブの<video>とHTMLMediaElementイベントを使います。
import { useEffect, useRef, useState, type ChangeEvent } from "react";
type CaptionTrack = {
src: string;
srcLang: string;
label: string;
default?: boolean;
};
type ProductionVideoPlayerProps = {
src: string;
title: string;
poster?: string;
captions?: CaptionTrack[];
};
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;
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 {
await video.play();
setError("");
} catch {
setError("Playback was blocked. Tap play again or check browser settings.");
}
} 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 player`}>
<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}
/>
))}
Your browser does not support the video element.
</video>
<div role="group" aria-label="Video controls">
<button type="button" onClick={togglePlay} aria-label={isPlaying ? "Pause video" : "Play video"}>
{isPlaying ? "Pause" : "Play"}
</button>
<label>
<span>Seek</span>
<input
type="range"
min="0"
max={duration || 0}
step="0.1"
value={duration ? currentTime : 0}
onChange={seek}
aria-valuetext={`${formatTime(currentTime)} of ${formatTime(duration)}`}
/>
</label>
<output>
{formatTime(currentTime)} / {formatTime(duration)}
</output>
<button type="button" onClick={toggleMute} aria-label={volume === 0 ? "Unmute video" : "Mute video"}>
{volume === 0 ? "Unmute" : "Mute"}
</button>
<label>
<span>Volume</span>
<input type="range" min="0" max="1" step="0.05" value={volume} onChange={changeVolume} />
</label>
<label>
<span>Speed</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>
);
}
アクセシビリティとパフォーマンスの落とし穴
最大の落とし穴は、ネイティブ操作を隠したのに同等の操作性を戻していない状態です。再生ボタンをdivで作る、シークバーにラベルがない、キーボードで速度変更できない、字幕がない、といった実装は見た目が良くても学習プロダクトには向きません。ボタンは本物のbutton、シークはinput type="range"、エラーはrole="alert"で伝えるのが基本です。
パフォーマンス面では、記事一覧やLPで複数の動画を同時に読み込む設計が危険です。poster画像に幅と高さを持たせ、任意視聴の動画はpreload="metadata"にし、動画本体はCDNから配信します。長尺講座を1本の巨大MP4だけで配ると、モバイル回線、海外視聴、途中離脱のすべてで不利になります。
計測の失敗もよくあります。毎秒イベントを送るより、再生開始、25%、50%、75%、完了、エラー、CTAクリックに絞った方が改善に使えます。学習サービスなら「完了率が低いレッスンはどれか」、メディアサイトなら「動画視聴後に会員登録へ進んだか」を見るべきです。
ロールアウトチェックリスト
- キーボード、タッチ、マウス、スクリーンリーダーのラベルを確認した。
- 字幕または文字起こしを用意し、重要情報を動画だけに閉じ込めていない。
- モバイルで
playsInlineが効き、意図しない全画面遷移が起きない。 - poster画像の比率とサイズを固定し、CLSを起こしていない。
- 初期表示に不要な動画へ
preload="auto"を付けていない。 - URL期限切れ、字幕欠落、ネットワーク低速、autoplayブロックをテストした。
- 再生開始、進捗、完了、エラー、CTAクリックの計測名を決めた。
- カスタム操作が壊れた場合にネイティブcontrolsへ戻す手順を用意した。
収益導線と検証メモ
動画プレーヤーは、無料記事を有料教材や相談につなげる導線にもなります。無料プレビューを記事に置き、理解が進んだ読者にワークシート、Gumroad商品、講座、または研修・相談を案内する流れが自然です。押し売りではなく、「この動画UIを自社の教材、社内研修、メディアサイトに合わせて設計したい人はこちら」という位置づけにします。
検証メモ: この構成で実際に試した結果、React側のisPlayingをボタン押下時の推測で変えるより、playとpauseイベントから同期する方が安定しました。video.play()はブラウザの自動再生制限で失敗することがあるため、try/catchで読者に再操作を促す必要があります。Claude Codeにレビューさせるなら、「media eventから状態同期しているか」「キーボードで全操作できるか」「初期ロードで動画本体を落としていないか」の3点を先に見せると、修正が実務に直結します。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。