ポッドキャスト

番組名とエピソード情報を表示し、15秒の早戻し・早送りと再生速度切替(1.0x〜1.5x)に対応したポッドキャストプレイヤー。長尺コンテンツの視聴を想定しています。

#css#js#media

ライブデモ

コード

HTML
<!-- ポッドキャストプレイヤー: 番組情報+15秒スキップ+再生速度切替+擬似再生 -->
<div class="stage">
  <section class="mp-player" data-duration="1654" aria-label="ポッドキャストプレイヤー">
    <div class="mp-player__top">
      <img class="mp-player__cover" src="https://picsum.photos/id/1025/240/240" alt="番組カバー: 夜更けのラジオ論" width="72" height="72">
      <div class="mp-player__meta">
        <p class="mp-player__show">夜更けのラジオ論 別冊</p>
        <p class="mp-player__episode">第58回 | 眠れない夜の音楽の話</p>
        <p class="mp-player__hosts">ナビゲーター: 律 &amp; 灯</p>
      </div>
    </div>

    <div class="mp-player__bar" tabindex="0" role="slider" aria-label="再生位置" aria-valuemin="0" aria-valuemax="1654" aria-valuenow="0" aria-valuetext="0分00秒 / 27分34秒">
      <div class="mp-player__bar-track">
        <div class="mp-player__bar-fill"></div>
        <div class="mp-player__bar-handle"></div>
      </div>
    </div>
    <div class="mp-player__time">
      <span class="mp-player__current">0:00</span>
      <span class="mp-player__dur">27:34</span>
    </div>

    <div class="mp-player__controls">
      <button type="button" class="mp-player__speed" aria-label="再生速度を切り替え">1.0x</button>

      <div class="mp-player__transport">
        <button type="button" class="mp-player__btn mp-player__back" aria-label="15秒戻す">
          <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 5V1L6 6l6 5V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z" fill="currentColor"/></svg>
          <span class="mp-player__skip-label" aria-hidden="true">15</span>
        </button>

        <button type="button" class="mp-player__btn mp-player__play" aria-label="再生と一時停止を切り替え" aria-pressed="false">
          <svg class="mp-icon mp-icon--play" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
          <svg class="mp-icon mp-icon--pause" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" hidden><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>
        </button>

        <button type="button" class="mp-player__btn mp-player__fwd" aria-label="15秒送る">
          <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 5V1l6 5-6 5V7a5 5 0 1 0 5 5h2a7 7 0 1 1-7-7z" fill="currentColor"/></svg>
          <span class="mp-player__skip-label" aria-hidden="true">15</span>
        </button>
      </div>

      <span class="mp-player__spacer" aria-hidden="true"></span>
    </div>
  </section>
</div>
CSS
/* ポッドキャストプレイヤー: 深い藍色のスタジオ調 */
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  padding: 24px;
  display: grid;
  place-items: center;
  font-family: "Hiragino Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 12% 15%, #253059 0%, transparent 46%),
    radial-gradient(circle at 90% 82%, #3a2a55 0%, transparent 50%),
    #131629;
  color: #eef0fb;
}

.stage { display: grid; place-items: center; width: 100%; }

.mp-player {
  width: min(400px, 92vw);
  padding: 24px 24px 20px;
  border-radius: 22px;
  background: linear-gradient(180deg, #1b2040, #171a34);
  box-shadow: 0 24px 55px rgba(6, 8, 20, .55), 0 0 0 1px rgba(255, 255, 255, .04) inset;
}

.mp-player__top {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 18px;
}
.mp-player__cover {
  width: 72px;
  height: 72px;
  border-radius: 16px;
  object-fit: cover;
  flex: none;
  box-shadow: 0 8px 22px rgba(0, 0, 0, .4);
}
.mp-player__meta { min-width: 0; }
.mp-player__show {
  margin: 0 0 4px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: #9fb0ff;
}
.mp-player__episode {
  margin: 0 0 4px;
  font-size: 14.5px;
  font-weight: 700;
  line-height: 1.3;
  color: #fff;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}
.mp-player__hosts { margin: 0; font-size: 11.5px; color: #8b90b8; }

.mp-player__bar {
  padding: 8px 0;
  cursor: pointer;
  outline-offset: 4px;
}
.mp-player__bar-track {
  position: relative;
  height: 4px;
  border-radius: 999px;
  background: rgba(255, 255, 255, .12);
}
.mp-player__bar-fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: 0%;
  border-radius: 999px;
  background: linear-gradient(90deg, #7c9bff, #b48cff);
}
.mp-player__bar-handle {
  position: absolute;
  top: 50%;
  left: 0%;
  width: 12px;
  height: 12px;
  margin-left: -6px;
  border-radius: 999px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, .4);
  transform: translateY(-50%) scale(0);
  transition: transform .15s ease;
}
.mp-player__bar:hover .mp-player__bar-handle,
.mp-player__bar:focus-visible .mp-player__bar-handle { transform: translateY(-50%) scale(1); }
.mp-player__bar:hover .mp-player__bar-track { height: 5px; }

.mp-player__time {
  display: flex;
  justify-content: space-between;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: #8b90b8;
  margin-bottom: 16px;
}

.mp-player__controls {
  display: flex;
  align-items: center;
  gap: 10px;
}

.mp-player__speed {
  font: inherit;
  font-size: 11.5px;
  font-weight: 800;
  letter-spacing: .01em;
  color: #cfd6ff;
  background: rgba(255, 255, 255, .08);
  border: none;
  border-radius: 999px;
  padding: 7px 12px;
  cursor: pointer;
  min-width: 52px;
  transition: background .18s ease, color .18s ease, transform .12s ease;
}
.mp-player__speed:hover { background: rgba(124, 155, 255, .22); color: #fff; }
.mp-player__speed:active { transform: scale(.94); }

.mp-player__transport {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 18px;
}

.mp-player__btn {
  position: relative;
  display: grid;
  place-items: center;
  border: none;
  background: transparent;
  color: #dfe3ff;
  cursor: pointer;
  border-radius: 999px;
  transition: color .18s ease, background .18s ease, transform .12s ease;
}
.mp-player__back, .mp-player__fwd { width: 38px; height: 38px; }
.mp-player__back:hover, .mp-player__fwd:hover { color: #fff; background: rgba(124, 155, 255, .16); }
.mp-player__skip-label {
  position: absolute;
  font-size: 8px;
  font-weight: 800;
  bottom: 8px;
}

.mp-player__play {
  width: 52px;
  height: 52px;
  color: #171a34;
  background: linear-gradient(135deg, #9fb0ff, #cfa8ff);
  box-shadow: 0 12px 26px rgba(124, 155, 255, .35);
}
.mp-player__play svg { transform: translateX(1px); }
.mp-player__play:hover { transform: translateY(-2px); box-shadow: 0 16px 32px rgba(124, 155, 255, .45); }
.mp-player__btn:active { transform: scale(.92); }

.mp-player__spacer { width: 52px; }

.mp-icon--pause[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
  .mp-player__btn, .mp-player__speed, .mp-player__bar-handle { transition: none; }
}
JavaScript
// ポッドキャストプレイヤー: 実音声は使わず、タイマーで再生位置を擬似的に進める
(() => {
  const player = document.querySelector('.mp-player');
  if (!player) return; // null安全

  const bar = player.querySelector('.mp-player__bar');
  const barFill = player.querySelector('.mp-player__bar-fill');
  const barHandle = player.querySelector('.mp-player__bar-handle');
  const curEl = player.querySelector('.mp-player__current');
  const durEl = player.querySelector('.mp-player__dur');
  const playBtn = player.querySelector('.mp-player__play');
  const backBtn = player.querySelector('.mp-player__back');
  const fwdBtn = player.querySelector('.mp-player__fwd');
  const speedBtn = player.querySelector('.mp-player__speed');
  const iconPlay = playBtn ? playBtn.querySelector('.mp-icon--play') : null;
  const iconPause = playBtn ? playBtn.querySelector('.mp-icon--pause') : null;

  if (!bar || !barFill || !barHandle || !curEl || !durEl) return;

  const reduceMotion = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false;

  const DURATION = parseFloat(player.dataset.duration) || 1654;
  const SPEEDS = [1, 1.25, 1.5];
  const TICK_MS = 200;
  const DEMO_MULTIPLIER = 9; // 実尺は長いため、デモとして体感しやすい速さで擬似進行させる

  const state = { current: 0, playing: false, speedIndex: 0 };

  const fmt = (s) => {
    const t = Math.max(0, Math.round(s));
    const m = Math.floor(t / 60);
    const sec = t % 60;
    return `${m}:${String(sec).padStart(2, '0')}`;
  };

  let timer = null;

  function render() {
    const frac = Math.min(1, state.current / DURATION);
    barFill.style.width = `${frac * 100}%`;
    barHandle.style.left = `${frac * 100}%`;
    bar.setAttribute('aria-valuenow', String(Math.round(state.current)));
    bar.setAttribute('aria-valuetext', `${fmt(state.current)} / ${fmt(DURATION)}`);
    curEl.textContent = fmt(state.current);
  }

  function setPlaying(next) {
    state.playing = next;
    player.classList.toggle('is-playing', next);
    if (playBtn) playBtn.setAttribute('aria-pressed', String(next));
    if (iconPlay) iconPlay.hidden = next;
    if (iconPause) iconPause.hidden = !next;

    clearInterval(timer);
    if (next) {
      timer = setInterval(() => {
        const speed = SPEEDS[state.speedIndex] || 1;
        state.current += (TICK_MS / 1000) * DEMO_MULTIPLIER * speed;
        if (state.current >= DURATION) state.current = 0; // カードとして常に生きて見えるようループ再生
        render();
      }, TICK_MS);
    }
  }

  function togglePlay() { setPlaying(!state.playing); }
  function skip(sec) { state.current = Math.min(DURATION, Math.max(0, state.current + sec)); render(); }

  function seekToFraction(frac) {
    state.current = Math.min(DURATION, Math.max(0, frac * DURATION));
    render();
  }
  function fracFromEvent(e) {
    const r = bar.getBoundingClientRect();
    const x = (e.touches && e.touches[0] ? e.touches[0].clientX : e.clientX) - r.left;
    return r.width ? Math.min(1, Math.max(0, x / r.width)) : 0;
  }

  if (playBtn) playBtn.addEventListener('click', togglePlay);
  if (backBtn) backBtn.addEventListener('click', () => skip(-15));
  if (fwdBtn) fwdBtn.addEventListener('click', () => skip(15));

  if (speedBtn) {
    speedBtn.addEventListener('click', () => {
      state.speedIndex = (state.speedIndex + 1) % SPEEDS.length;
      speedBtn.textContent = `${SPEEDS[state.speedIndex].toFixed(2).replace(/0$/, '')}x`;
    });
  }

  let dragging = false;
  bar.addEventListener('pointerdown', (e) => {
    dragging = true;
    seekToFraction(fracFromEvent(e));
    bar.setPointerCapture && e.pointerId != null && bar.setPointerCapture(e.pointerId);
  });
  bar.addEventListener('pointermove', (e) => { if (dragging) seekToFraction(fracFromEvent(e)); });
  window.addEventListener('pointerup', () => { dragging = false; });
  bar.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowRight') skip(15);
    else if (e.key === 'ArrowLeft') skip(-15);
    else if (e.key === 'Home') { state.current = 0; render(); }
    else if (e.key === 'End') { state.current = DURATION; render(); }
    else return;
    e.preventDefault();
  });

  render();
  // カード上でも「生きている」ことが伝わるよう既定で再生。モーション低減設定時は静止状態から開始
  if (!reduceMotion) setPlaying(true);
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「ポッドキャスト」の効果を追加してください。

# 追加してほしい効果
ポッドキャスト(チャット & メディア)
番組名とエピソード情報を表示し、15秒の早戻し・早送りと再生速度切替(1.0x〜1.5x)に対応したポッドキャストプレイヤー。長尺コンテンツの視聴を想定しています。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- ポッドキャストプレイヤー: 番組情報+15秒スキップ+再生速度切替+擬似再生 -->
<div class="stage">
  <section class="mp-player" data-duration="1654" aria-label="ポッドキャストプレイヤー">
    <div class="mp-player__top">
      <img class="mp-player__cover" src="https://picsum.photos/id/1025/240/240" alt="番組カバー: 夜更けのラジオ論" width="72" height="72">
      <div class="mp-player__meta">
        <p class="mp-player__show">夜更けのラジオ論 別冊</p>
        <p class="mp-player__episode">第58回 | 眠れない夜の音楽の話</p>
        <p class="mp-player__hosts">ナビゲーター: 律 &amp; 灯</p>
      </div>
    </div>

    <div class="mp-player__bar" tabindex="0" role="slider" aria-label="再生位置" aria-valuemin="0" aria-valuemax="1654" aria-valuenow="0" aria-valuetext="0分00秒 / 27分34秒">
      <div class="mp-player__bar-track">
        <div class="mp-player__bar-fill"></div>
        <div class="mp-player__bar-handle"></div>
      </div>
    </div>
    <div class="mp-player__time">
      <span class="mp-player__current">0:00</span>
      <span class="mp-player__dur">27:34</span>
    </div>

    <div class="mp-player__controls">
      <button type="button" class="mp-player__speed" aria-label="再生速度を切り替え">1.0x</button>

      <div class="mp-player__transport">
        <button type="button" class="mp-player__btn mp-player__back" aria-label="15秒戻す">
          <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 5V1L6 6l6 5V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z" fill="currentColor"/></svg>
          <span class="mp-player__skip-label" aria-hidden="true">15</span>
        </button>

        <button type="button" class="mp-player__btn mp-player__play" aria-label="再生と一時停止を切り替え" aria-pressed="false">
          <svg class="mp-icon mp-icon--play" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
          <svg class="mp-icon mp-icon--pause" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" hidden><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>
        </button>

        <button type="button" class="mp-player__btn mp-player__fwd" aria-label="15秒送る">
          <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 5V1l6 5-6 5V7a5 5 0 1 0 5 5h2a7 7 0 1 1-7-7z" fill="currentColor"/></svg>
          <span class="mp-player__skip-label" aria-hidden="true">15</span>
        </button>
      </div>

      <span class="mp-player__spacer" aria-hidden="true"></span>
    </div>
  </section>
</div>

【CSS】
/* ポッドキャストプレイヤー: 深い藍色のスタジオ調 */
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  padding: 24px;
  display: grid;
  place-items: center;
  font-family: "Hiragino Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 12% 15%, #253059 0%, transparent 46%),
    radial-gradient(circle at 90% 82%, #3a2a55 0%, transparent 50%),
    #131629;
  color: #eef0fb;
}

.stage { display: grid; place-items: center; width: 100%; }

.mp-player {
  width: min(400px, 92vw);
  padding: 24px 24px 20px;
  border-radius: 22px;
  background: linear-gradient(180deg, #1b2040, #171a34);
  box-shadow: 0 24px 55px rgba(6, 8, 20, .55), 0 0 0 1px rgba(255, 255, 255, .04) inset;
}

.mp-player__top {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 18px;
}
.mp-player__cover {
  width: 72px;
  height: 72px;
  border-radius: 16px;
  object-fit: cover;
  flex: none;
  box-shadow: 0 8px 22px rgba(0, 0, 0, .4);
}
.mp-player__meta { min-width: 0; }
.mp-player__show {
  margin: 0 0 4px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: #9fb0ff;
}
.mp-player__episode {
  margin: 0 0 4px;
  font-size: 14.5px;
  font-weight: 700;
  line-height: 1.3;
  color: #fff;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}
.mp-player__hosts { margin: 0; font-size: 11.5px; color: #8b90b8; }

.mp-player__bar {
  padding: 8px 0;
  cursor: pointer;
  outline-offset: 4px;
}
.mp-player__bar-track {
  position: relative;
  height: 4px;
  border-radius: 999px;
  background: rgba(255, 255, 255, .12);
}
.mp-player__bar-fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: 0%;
  border-radius: 999px;
  background: linear-gradient(90deg, #7c9bff, #b48cff);
}
.mp-player__bar-handle {
  position: absolute;
  top: 50%;
  left: 0%;
  width: 12px;
  height: 12px;
  margin-left: -6px;
  border-radius: 999px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, .4);
  transform: translateY(-50%) scale(0);
  transition: transform .15s ease;
}
.mp-player__bar:hover .mp-player__bar-handle,
.mp-player__bar:focus-visible .mp-player__bar-handle { transform: translateY(-50%) scale(1); }
.mp-player__bar:hover .mp-player__bar-track { height: 5px; }

.mp-player__time {
  display: flex;
  justify-content: space-between;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: #8b90b8;
  margin-bottom: 16px;
}

.mp-player__controls {
  display: flex;
  align-items: center;
  gap: 10px;
}

.mp-player__speed {
  font: inherit;
  font-size: 11.5px;
  font-weight: 800;
  letter-spacing: .01em;
  color: #cfd6ff;
  background: rgba(255, 255, 255, .08);
  border: none;
  border-radius: 999px;
  padding: 7px 12px;
  cursor: pointer;
  min-width: 52px;
  transition: background .18s ease, color .18s ease, transform .12s ease;
}
.mp-player__speed:hover { background: rgba(124, 155, 255, .22); color: #fff; }
.mp-player__speed:active { transform: scale(.94); }

.mp-player__transport {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 18px;
}

.mp-player__btn {
  position: relative;
  display: grid;
  place-items: center;
  border: none;
  background: transparent;
  color: #dfe3ff;
  cursor: pointer;
  border-radius: 999px;
  transition: color .18s ease, background .18s ease, transform .12s ease;
}
.mp-player__back, .mp-player__fwd { width: 38px; height: 38px; }
.mp-player__back:hover, .mp-player__fwd:hover { color: #fff; background: rgba(124, 155, 255, .16); }
.mp-player__skip-label {
  position: absolute;
  font-size: 8px;
  font-weight: 800;
  bottom: 8px;
}

.mp-player__play {
  width: 52px;
  height: 52px;
  color: #171a34;
  background: linear-gradient(135deg, #9fb0ff, #cfa8ff);
  box-shadow: 0 12px 26px rgba(124, 155, 255, .35);
}
.mp-player__play svg { transform: translateX(1px); }
.mp-player__play:hover { transform: translateY(-2px); box-shadow: 0 16px 32px rgba(124, 155, 255, .45); }
.mp-player__btn:active { transform: scale(.92); }

.mp-player__spacer { width: 52px; }

.mp-icon--pause[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
  .mp-player__btn, .mp-player__speed, .mp-player__bar-handle { transition: none; }
}

【JavaScript】
// ポッドキャストプレイヤー: 実音声は使わず、タイマーで再生位置を擬似的に進める
(() => {
  const player = document.querySelector('.mp-player');
  if (!player) return; // null安全

  const bar = player.querySelector('.mp-player__bar');
  const barFill = player.querySelector('.mp-player__bar-fill');
  const barHandle = player.querySelector('.mp-player__bar-handle');
  const curEl = player.querySelector('.mp-player__current');
  const durEl = player.querySelector('.mp-player__dur');
  const playBtn = player.querySelector('.mp-player__play');
  const backBtn = player.querySelector('.mp-player__back');
  const fwdBtn = player.querySelector('.mp-player__fwd');
  const speedBtn = player.querySelector('.mp-player__speed');
  const iconPlay = playBtn ? playBtn.querySelector('.mp-icon--play') : null;
  const iconPause = playBtn ? playBtn.querySelector('.mp-icon--pause') : null;

  if (!bar || !barFill || !barHandle || !curEl || !durEl) return;

  const reduceMotion = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false;

  const DURATION = parseFloat(player.dataset.duration) || 1654;
  const SPEEDS = [1, 1.25, 1.5];
  const TICK_MS = 200;
  const DEMO_MULTIPLIER = 9; // 実尺は長いため、デモとして体感しやすい速さで擬似進行させる

  const state = { current: 0, playing: false, speedIndex: 0 };

  const fmt = (s) => {
    const t = Math.max(0, Math.round(s));
    const m = Math.floor(t / 60);
    const sec = t % 60;
    return `${m}:${String(sec).padStart(2, '0')}`;
  };

  let timer = null;

  function render() {
    const frac = Math.min(1, state.current / DURATION);
    barFill.style.width = `${frac * 100}%`;
    barHandle.style.left = `${frac * 100}%`;
    bar.setAttribute('aria-valuenow', String(Math.round(state.current)));
    bar.setAttribute('aria-valuetext', `${fmt(state.current)} / ${fmt(DURATION)}`);
    curEl.textContent = fmt(state.current);
  }

  function setPlaying(next) {
    state.playing = next;
    player.classList.toggle('is-playing', next);
    if (playBtn) playBtn.setAttribute('aria-pressed', String(next));
    if (iconPlay) iconPlay.hidden = next;
    if (iconPause) iconPause.hidden = !next;

    clearInterval(timer);
    if (next) {
      timer = setInterval(() => {
        const speed = SPEEDS[state.speedIndex] || 1;
        state.current += (TICK_MS / 1000) * DEMO_MULTIPLIER * speed;
        if (state.current >= DURATION) state.current = 0; // カードとして常に生きて見えるようループ再生
        render();
      }, TICK_MS);
    }
  }

  function togglePlay() { setPlaying(!state.playing); }
  function skip(sec) { state.current = Math.min(DURATION, Math.max(0, state.current + sec)); render(); }

  function seekToFraction(frac) {
    state.current = Math.min(DURATION, Math.max(0, frac * DURATION));
    render();
  }
  function fracFromEvent(e) {
    const r = bar.getBoundingClientRect();
    const x = (e.touches && e.touches[0] ? e.touches[0].clientX : e.clientX) - r.left;
    return r.width ? Math.min(1, Math.max(0, x / r.width)) : 0;
  }

  if (playBtn) playBtn.addEventListener('click', togglePlay);
  if (backBtn) backBtn.addEventListener('click', () => skip(-15));
  if (fwdBtn) fwdBtn.addEventListener('click', () => skip(15));

  if (speedBtn) {
    speedBtn.addEventListener('click', () => {
      state.speedIndex = (state.speedIndex + 1) % SPEEDS.length;
      speedBtn.textContent = `${SPEEDS[state.speedIndex].toFixed(2).replace(/0$/, '')}x`;
    });
  }

  let dragging = false;
  bar.addEventListener('pointerdown', (e) => {
    dragging = true;
    seekToFraction(fracFromEvent(e));
    bar.setPointerCapture && e.pointerId != null && bar.setPointerCapture(e.pointerId);
  });
  bar.addEventListener('pointermove', (e) => { if (dragging) seekToFraction(fracFromEvent(e)); });
  window.addEventListener('pointerup', () => { dragging = false; });
  bar.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowRight') skip(15);
    else if (e.key === 'ArrowLeft') skip(-15);
    else if (e.key === 'Home') { state.current = 0; render(); }
    else if (e.key === 'End') { state.current = DURATION; render(); }
    else return;
    e.preventDefault();
  });

  render();
  // カード上でも「生きている」ことが伝わるよう既定で再生。モーション低減設定時は静止状態から開始
  if (!reduceMotion) setPlaying(true);
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。