オーディオプレーヤーをReactで自作: シーク・音量・プレイリストまで
audio要素とHTMLMediaElementでオーディオプレーヤーを自作。再生・シーク・音量・プレイリスト・アクセシビリティを、コピペで動くReactコード付きで解説します。
ポッドキャストの試聴プレーヤーを埋め込んだ翌日、リスナーから1通だけメッセージが来ました。「シークバーをドラッグすると、たまに最初に戻っちゃうんですけど」。
再現してみたら、本当に戻る。再生ボタンも一時停止も問題なく動く。なのに、バーを掴んで離した瞬間だけ、再生位置が0:00にリセットされる日があったんです。
原因は拍子抜けするほど地味でした。シーク中もブラウザが timeupdate を投げ続けていて、僕のコードがその値で currentTime を上書きしていた。つまり、ユーザーが指で動かした位置を、コンマ数秒後にブラウザが押し戻していたわけです。
オーディオプレーヤーの自作って、<audio> を1行置けば終わり——に見えて、実は「いつ状態を信じて、いつ無視するか」の判断がぜんぶ詰まっています。この記事では、その地雷を踏み抜いた経験をもとに、再生・シーク・音量・プレイリストまでコピペで動くReactコードで組み立てます。動画プレーヤーが必要な人は、姉妹記事の動画プレーヤーをReactで自作へどうぞ。ここは音声だけに絞ります。
この記事の要点
- オーディオプレーヤーの土台は
<audio>要素と、それを操るHTMLMediaElement。play()/pause()/currentTime/volumeの4つを押さえれば8割は動く。 - 進捗バーは
timeupdateで更新するが、ユーザーがシーク中はその更新を止める。これを忘れるとバーが暴れる。 durationはメタデータ読み込み前はNaN。loadedmetadataを待ち、表示はduration || 0で守る。- プレイリストは「現在のindex」を1つ持つだけ。曲送りは
endedイベントに任せると素直に組める。 - アクセシビリティは後付けが地獄。
<button>を使い、aria-labelとキーボード操作を最初から入れる。
まず <audio> と HTMLMediaElement の関係を掴む
<audio> はHTMLのタグです。これをブラウザが解釈すると、JavaScriptから操作できるオブジェクトになります。それが HTMLMediaElement です。<video> も同じ系列なので、ここで覚えた知識はほぼそのまま動画に転用できます。
難しく考えなくていいです。要は、こういう操作ができるリモコンが手に入る、というだけ。
| やりたいこと | 使うもの | ひとことメモ |
|---|---|---|
| 再生する | audio.play() | Promiseを返す。失敗することがある(後述) |
| 止める | audio.pause() | 失敗しない。素直 |
| 今どこ? | audio.currentTime | 秒。代入すればシークになる |
| 全体の長さ | audio.duration | 読み込み前は NaN に注意 |
| 音量 | audio.volume | 0〜1。0.8 で80% |
| 終わった? | ended イベント | 次の曲へ送る合図に使う |
| 位置が動いた | timeupdate イベント | 1秒に数回飛んでくる |
僕が最初に勘違いしていたのは、「Web Audio APIを使わないと本格的なプレーヤーは作れない」という思い込みでした。実際は逆です。再生・停止・シーク・音量・プレイリストは、ぜんぶ HTMLMediaElement だけで足ります。Web Audio APIが要るのは、波形表示やイコライザーみたいな“音を解析・加工する”機能だけ。その話はWeb Audio APIで波形を描くに切り出しました。まずはリモコン(HTMLMediaElement)に集中するのが、遠回りに見えていちばん速いです。
仕様の一次情報は、MDNの<audio> 要素とHTMLMediaElementが頼りになります。イベント名で迷ったら、まずここを開いてください。
状態は3つだけ安定させる
ボタンを増やす前に、土台になる状態を決めます。僕はここを欲張って失敗しました。「再生速度」「ループ」「シャッフル」を最初から全部stateにしたら、相互作用でバグが増えて手に負えなくなったんです。
最低限、安定させるべきは次の3つだけです。
- 今どの曲か(
currentTrackIndex) - 今何秒か(
currentTime) - 長さは確定したか(
duration、つまりメタデータが読めたか)
この3つが揺れなければ、シークバーも時間表示もプレイリストのハイライトも、全部そこから導けます。逆にここが揺れると、何を足しても砂上の楼閣になります。再生中フラグ(isPlaying)も持ちますが、これは「ボタンの見た目」のためのもので、真実は常に audio.paused にある、と割り切ると混乱しません。
コピペで動くオーディオプレーヤー
ここが本体です。React + TypeScriptで、再生・一時停止・前後送り・シーク・音量・プレイリストまで1つのコンポーネントに収めました。Next.jsのApp Routerならファイル先頭の 'use client' をそのまま、それ以外の構成なら適宜外して使ってください。tracks には必ず1件以上渡し、src は public 配下かCDN上の実在する音声ファイルを指定します。
// src/components/AudioPlayer.tsx
'use client';
import { useRef, useState, useEffect } from 'react';
interface Track {
id: string;
title: string;
artist: string;
src: string;
duration: number; // 一覧表示用のおおよその秒数(実値はloadedmetadataで上書き)
coverArt?: string;
}
interface AudioPlayerProps {
tracks: Track[];
initialTrackIndex?: number;
}
export function AudioPlayer({ tracks, initialTrackIndex = 0 }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTrackIndex, setCurrentTrackIndex] = useState(initialTrackIndex);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(0.8);
// シーク中フラグ。ドラッグ中はtimeupdateの上書きを無視する門番
const [isSeeking, setIsSeeking] = useState(false);
const currentTrack = tracks[currentTrackIndex];
// 曲が変わるたびにイベントを張り直す
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
// ユーザーがシーク中はブラウザの再生位置で上書きしない(バーが暴れる原因)
const onTimeUpdate = () => {
if (!isSeeking) setCurrentTime(audio.currentTime);
};
// durationはここで初めて確定する。読み込み前はNaNなので0で守る
const onLoadedMetadata = () => setDuration(audio.duration || 0);
const onEnded = () => playNext();
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('ended', onEnded);
audio.addEventListener('play', onPlay);
audio.addEventListener('pause', onPause);
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('play', onPlay);
audio.removeEventListener('pause', onPause);
};
// isSeekingを依存に入れて、最新のフラグを参照させる
}, [currentTrackIndex, isSeeking]);
// 再生はPromise。拒否されることがあるので必ずcatchする
const safePlay = async () => {
try {
await audioRef.current?.play();
} catch {
// 自動再生ブロックなど。ボタンを押してもらう前提に倒す
setIsPlaying(false);
}
};
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) safePlay();
else audio.pause();
};
// 曲送りはcanplayを待ってから再生(setTimeoutでごまかさない)
const goToTrack = (index: number) => {
const audio = audioRef.current;
if (!audio) return;
setCurrentTrackIndex(index);
setCurrentTime(0);
const onCanPlay = () => {
safePlay();
audio.removeEventListener('canplay', onCanPlay);
};
audio.addEventListener('canplay', onCanPlay);
};
const playNext = () => goToTrack((currentTrackIndex + 1) % tracks.length);
const playPrevious = () => {
// 3秒以上再生していたら頭出し、そうでなければ前の曲へ
if (currentTime > 3 && audioRef.current) {
audioRef.current.currentTime = 0;
setCurrentTime(0);
} else {
goToTrack((currentTrackIndex - 1 + tracks.length) % tracks.length);
}
};
const formatTime = (sec: number) => {
if (!Number.isFinite(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden max-w-md mx-auto">
<audio ref={audioRef} src={currentTrack.src} preload="metadata" />
{/* カバーアート */}
<div className="aspect-square bg-gray-200 dark:bg-gray-700 relative">
{currentTrack.coverArt ? (
<img src={currentTrack.coverArt} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-6xl text-gray-400" aria-hidden="true">♪</div>
)}
</div>
{/* トラック情報 */}
<div className="p-6">
<h3 className="text-lg font-bold dark:text-white truncate">{currentTrack.title}</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">{currentTrack.artist}</p>
{/* シークバー:onChangeでは状態だけ動かし、確定はpointerUpで実シーク */}
<div className="mt-4">
<input
type="range"
min={0}
max={duration || 0}
value={currentTime}
aria-label="再生位置"
onPointerDown={() => setIsSeeking(true)}
onChange={(e) => setCurrentTime(Number(e.target.value))}
onPointerUp={(e) => {
const time = Number((e.target as HTMLInputElement).value);
if (audioRef.current) audioRef.current.currentTime = time;
setIsSeeking(false);
}}
className="w-full h-1 accent-blue-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* コントロール:見た目の記号ではなくaria-labelで意味を伝える */}
<div className="flex items-center justify-center gap-6 mt-4">
<button onClick={playPrevious} aria-label="前の曲" className="text-2xl dark:text-white hover:text-blue-600">⏮</button>
<button
onClick={togglePlay}
aria-label={isPlaying ? '一時停止' : '再生'}
className="w-14 h-14 rounded-full bg-blue-600 text-white text-2xl flex items-center justify-center hover:bg-blue-700"
>
{isPlaying ? '⏸' : '▶'}
</button>
<button onClick={playNext} aria-label="次の曲" className="text-2xl dark:text-white hover:text-blue-600">⏭</button>
</div>
{/* 音量 */}
<div className="flex items-center gap-2 mt-4">
<span className="text-sm dark:text-gray-400" aria-hidden="true">🔊</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
aria-label="音量"
onChange={(e) => {
const vol = Number(e.target.value);
if (audioRef.current) audioRef.current.volume = vol;
setVolume(vol);
}}
className="flex-1 h-1 accent-blue-600"
/>
</div>
</div>
{/* プレイリスト */}
<ul className="border-t dark:border-gray-700 max-h-60 overflow-y-auto">
{tracks.map((track, index) => (
<li key={track.id}>
<button
onClick={() => goToTrack(index)}
aria-current={index === currentTrackIndex ? 'true' : undefined}
className={`w-full flex items-center gap-3 p-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 ${
index === currentTrackIndex ? 'bg-blue-50 dark:bg-blue-900/30' : ''
}`}
>
<span className="text-xs text-gray-400 w-6 text-right">
{index === currentTrackIndex && isPlaying ? '♪' : index + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium dark:text-white truncate">{track.title}</p>
<p className="text-xs text-gray-500 truncate">{track.artist}</p>
</div>
<span className="text-xs text-gray-400">{formatTime(track.duration)}</span>
</button>
</li>
))}
</ul>
</div>
);
}
使うときはこう渡します。src は実在ファイルにしてください。
<AudioPlayer
tracks={[
{ id: '1', title: '第1回 はじめに', artist: 'ClaudeCodeLab', src: '/audio/ep01.mp3', duration: 612 },
{ id: '2', title: '第2回 環境構築', artist: 'ClaudeCodeLab', src: '/audio/ep02.mp3', duration: 845 },
]}
/>
元の実装から変えた勘所は3つです。①シーク中フラグで timeupdate の上書きを止めた(冒頭の“勝手に戻る”バグの真犯人)。②曲送りを setTimeout(…, 100) から canplay 待ちに変えた。③play() を必ず catch し、isPlaying の真実を play / pause イベントに寄せた。この3つで、デモでは動くけど本番で崩れる、を大幅に減らせます。Reactで ref を握る作法に不安があれば、useRef のリファレンスも合わせて読むと腑に落ちます。
進捗と timeupdate のうまい付き合い方
timeupdate は便利ですが、くせがあります。発火の間隔はブラウザ任せで、だいたい1秒に4回前後。これをそのままシークバーに流すと、ふつうに動きます。問題はユーザーがバーを掴んでいる間も発火し続けることです。
だから門番を1つ置きます。isSeeking が true の間は timeupdate を無視し、指を離した瞬間(onPointerUp)に実際の currentTime を代入して、フラグを下ろす。たったこれだけで、バーの取り合いが終わります。
計測イベントを入れたい場合も、この timeupdate が起点になります。たとえば「再生開始」「50%到達」「最後まで再生」を送ると、どの教材が途中で離脱されているかが見えてきます。50%判定は currentTime / duration >= 0.5 を一度だけ送る、というふうに、duration が確定してから計算するのがコツです。ここでも duration が NaN だと判定が壊れるので、loadedmetadata の後に限る、を徹底します。
アクセシビリティを後回しにしない
音声プレーヤーは、音が出る性質上「聞こえている人だけのもの」と思われがちですが、操作するのは全員です。キーボードしか使わない人、スクリーンリーダーを使う人にも、再生・停止・シークが届かないといけません。
後付けは本当に大変なので、最初から次を守るだけで段違いに良くなります。
- コントロールは
<div onClick>ではなく必ず<button>。これだけでTabキーで辿れて、Enter/Spaceで押せます。 - 記号(▶ ⏸ ⏮)はスクリーンリーダーに意味が伝わりません。
aria-label="再生"のように言葉を添える。アイコンだけの要素はaria-hidden="true"で読み飛ばさせる。 - シークバーと音量は
<input type="range">を使う。これはネイティブで矢印キー操作に対応していて、aria-labelを付ければ役割も伝わります。 - 再生中の曲は
aria-currentで示す。視覚のハイライト(背景色)だけに頼らない。
上のコンポーネントには、これらをすべて織り込んであります。もっと踏み込んだ網羅的なチェック(コントラスト、フォーカスリング、axeでの自動検査)は、アクセシビリティ実装の実践ワークフローにまとめました。プレーヤー単体で完結させず、サイト全体の基準と揃えるのがおすすめです。
僕がやらかした失敗3つ
正直に書きます。最初に作ったプレーヤーは、デモでは完璧で、本番で次々こけました。
ひとつ目は冒頭のシークバーが勝手に戻るやつ。timeupdate を無条件に信じていたのが原因でした。ブラウザの発火とユーザーの操作がぶつかる、という発想がなかった。isSeeking の門番を置いて解決しました。
ふたつ目は、自動再生を前提にしたこと。ページを開いたら鳴る、という仕様にしたら、多くのブラウザに play() を拒否されて無音のまま。play() がPromiseを返すことすら知らなかったんです。今は必ず catch して、ダメなら「再生ボタンを押してください」に倒します。スマホ、特にiOSはこの制約が厳しいので、最初からユーザー操作起点で設計するのが安全です。
みっつ目は、duration を数値だと信じきったこと。メタデータが届く前は NaN になることがあって、シークバーの max に NaN が入ると挙動が不安定になる。loadedmetadata を待って入れ、表示は duration || 0 と Number.isFinite で守る。地味ですが、これで「たまにバーが効かない」が消えました。
よくある質問
Q. Web Audio APIは使わなくていいんですか?
A. 再生・シーク・音量・プレイリストだけなら不要です。HTMLMediaElement で足ります。波形表示、イコライザー、エフェクトを足したくなったら初めて接続します。詳しくはWeb Audio APIで波形を描くへ。
Q. ページを開いたら自動で再生したいのですが?
A. ほぼ無理だと思ってください。多くのブラウザがユーザー操作なしの play() を拒否します。どうしても鳴らしたい場合は muted 状態でなら通ることがありますが、音声プレーヤーで無音再生は無意味なので、ボタンを押させる設計に倒すのが現実的です。
Q. シークバーがカクつく・たまに戻るのはなぜ?
A. シーク中も timeupdate がバーを上書きしているからです。本記事のように isSeeking フラグを立て、ドラッグ中はブラウザの位置で上書きしないようにすれば直ります。
Q. CDN上の音声で波形だけ出ません。
A. CORSが原因のことが多いです。AnalyserNode に音声を流すには、音声ファイルのレスポンスに適切なCORSヘッダーが要ります。再生はできるのに解析だけ失敗する、という症状ならまずここを疑ってください。
Q. 再生速度(1.25倍など)はどう足す?
A. audio.playbackRate = 1.25 を代入するだけです。stateを1つ増やして、ボタンで 0.75 / 1 / 1.25 / 1.5 を切り替える形が定番。土台が安定していれば、追加は驚くほど簡単です。React側の状態設計はClaude CodeでReact開発も参考になります。
実際に試した結果
冒頭の“勝手に戻るシークバー”を直してから、僕はプレーヤーづくりの順番を変えました。先にエフェクトや見た目を盛らない。まず「現在の曲・現在時刻・メタデータ確定」の3つを石みたいに固める。それから timeupdate の門番、play() の catch、loadedmetadata 待ち、という“壊れやすい3点”を潰す。ここまでやって初めて、再生速度やループみたいな飾りを足す。
この順番にしてから、ローカルで動いたのに本番で無音、という事故がほぼゼロになりました。派手な波形より、基本イベントを丁寧に扱うほうが、結局「ちゃんと使えるプレーヤー」に近づく——というのが、何度も転んでたどり着いた実感です。スマホ対応やレイアウト崩れが気になる人はレスポンシブデザインの実装へ。教材やメディアへの組み込み方ごと整理したい場合は、研修・相談で計測イベントやCTA設計まで一緒に詰められます。
無料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分の型を紹介します。