Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

YT.Playerでdivを置き換える埋め込み方式は、タイミング次第で黒画面のまま再生されない

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

YT.Playerにdiv要素を渡して初期化する方式は、divがiframeへ置き換わるまで画面には何も表示されず、タイミング次第で黒画面のまま再生が始まらないことがある。

結論

YouTube IFrame APIを new window.YT.Player(containerRef.current, {...}) のようにdiv要素を渡して初期化する方式は、そのdivがAPI内部でiframeに置き換わるまで画面には何も表示されない。読み込みのタイミング次第で黒画面のまま再生が始まらないことがあった。src付きの<iframe>をJSXで直接描画し、YT APIは後からそのiframeへelement idで紐づける方式に変えると、動画はAPIの読み込みを待たずに表示される。

症状

学習動画をYouTube埋め込みで再生するコンポーネントで、動画エリアが黒いままになり再生が始まらない不具合が起きていた。同じ日の2時間前に、埋め込みが失敗した場合のフォールバックとして onError ハンドラを追加していた。

onError: () => {
  setEmbedError(true)
  stopPolling()
},

embedErrortrue になると「YouTubeで見る」への外部リンクに切り替わる作りだったが、この黒画面ではその分岐に一度も入らなかった。フォールバックを足したはずなのに、実際に起きている黒画面はそのフォールバックの対象外だった。

原因

初期化はdiv要素をrefで受け取り、それをそのままYT.Playerのコンストラクタへ渡す方式だった。

function initPlayer() {
  if (!containerRef.current) return
  playerRef.current = new window.YT.Player(containerRef.current, {
    videoId: youtubeId!,
    height: '100%',
    width: '100%',
    playerVars: { modestbranding: 1, rel: 0, fs: 1 },
    events: {
      onReady: (e) => { ... },
      onStateChange: (e) => { ... },
      onError: () => {
        setEmbedError(true)
        stopPolling()
      },
    },
  })
}

呼び出し元はこの initPlayer を、APIスクリプトが読み込み済みならすぐに、未読み込みなら onYouTubeIframeAPIReady のコールバック経由で呼んでいた。

if (window.YT?.Player) {
  initPlayer()
} else {
  const prev = window.onYouTubeIframeAPIReady
  window.onYouTubeIframeAPIReady = () => { prev?.(); initPlayer() }
  if (!document.getElementById('yt-iframe-api')) {
    const s  = document.createElement('script')
    s.id     = 'yt-iframe-api'
    s.src    = 'https://www.youtube.com/iframe_api'
    document.head.appendChild(s)
  }
}

一方、画面に描画していたのは中身が空のdivだけだった。

<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0,
  borderRadius: '0.75rem', overflow: 'hidden', background: '#000' }}>
  <div ref={containerRef}
    style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }} />
</div>

この構成では、動画が見える状態になるまでに「APIスクリプトの読み込み」「onYouTubeIframeAPIReady の発火」「YT.Player() がdivをiframeへ置き換える」という3段階が挟まる。background: '#000' はこの間じゅう表示され続けるので、この置き換えが期待どおりに進まないと画面はただ黒いまま止まる。

onError は、YouTube IFrame APIが再生不可などのエラーコードを実際に返した場合にしか呼ばれない。divがiframeへ置き換わらずに止まる今回の黒画面は、APIからエラーコードが返るタイプの失敗ではなかった。そのため2時間前に追加したばかりの embedError によるフォールバックは、この黒画面を一度も検知できなかった。

直す

コンテナのdivをrefで渡す方式をやめ、src 付きの <iframe> をJSXで直接描画する方式に変えた。

<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0,
  borderRadius: '0.75rem', overflow: 'hidden', background: '#000' }}>
  <iframe
    id={iframeId}
    src={`https://www.youtube.com/embed/${youtubeId}?enablejsapi=1&rel=0&modestbranding=1`}
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    allowFullScreen
    style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' }}
  />
</div>

YT.Playerの初期化は、既にDOM上にあるこのiframeへid文字列でアタッチするだけの役割に縮小した。

// iframeは既にDOMにあるので id で渡してアタッチ
function attachPlayer() {
  playerRef.current = new window.YT.Player(iframeId, {
    events: {
      onReady: (e) => {
        totalSecRef.current = e.target.getDuration() || durationMinutes * 60
      },
      onStateChange: (e) => { ... },
      onError: () => stopPolling(),
    },
  })
}

この変更に合わせて、YT.Player の型定義も第一引数がDOM要素専用だったものをid文字列も受け付ける形に直した。

Player: new (el: string | HTMLElement, opts: YTPlayerOptions) => YTPlayer

iframeのsrcはReactのレンダリングと同時に確定するため、動画自体はYouTube IFrame APIのスクリプト読み込みやコールバックの完了を待たずに表示される。早送り防止や30秒ごとの視聴進捗送信、90%視聴での修了判定といったpollTimerheartbeatTimerまわりのロジックは変更しておらず、attachPlayerが呼ばれてAPIとiframeが紐づいた後に引き続き動く。

再発防止

embedErrorによるフォールバックは、YouTube IFrame APIが明示的にエラーコードを返す失敗だけを想定したものだった。「初期化そのものが完了しない」という失敗はこの対象に入っておらず、追加した2時間後に同じ症状で気づき、フォールバックを直すのではなく初期化方式自体を変える形で解消した。

表示をAPIの初期化完了に依存させない今回の構成では、動画が映るかどうかはブラウザがiframeのsrcを読み込めるかどうかだけで決まる。YouTube IFrame APIの読み込みやonYouTubeIframeAPIReadyのタイミングは、進捗トラッキングが有効になるまでの時間には関係しても、動画そのものが黒画面のまま止まる経路には入らなくなった。

よくある質問

Q1なぜdivを渡す初期化方式だと黒画面になることがあるのですか?

new window.YT.Player(containerRef.current, {...}) は、YouTube IFrame APIの読み込みとコールバックが完了してdivがiframeに置き換わるまで、画面には空のdivしか無い。読み込みのタイミング次第でこの置き換えが完了せず、黒画面のまま再生が始まらないことがあった。

Q2その2時間前に追加したonErrorハンドラでは検知できなかったのですか?

できなかった。onErrorはYouTube IFrame APIが再生不可などのエラーコードを返した場合にしか呼ばれず、divがiframeへ置き換わらないまま止まる黒画面はAPIからエラーが返る形の失敗ではなかった。embedErrorをtrueにする分岐には一度も入らず、フォールバックのリンクも表示されなかった。

Q3直すときに何を変えたのですか?

コンテナのdivをrefで渡す方式をやめ、src付きの<iframe>をJSXで直接描画するようにした。YT.Playerの初期化はiframeのid文字列を渡してアタッチするだけの役割に変え、型定義もel: HTMLElementからel: string | HTMLElementに変更した。

Q4iframeを直接描画すると何が変わるのですか?

動画の表示はReactのレンダリングと同時に決まり、YouTube IFrame APIのscript読み込みやコールバックの完了を待たなくなる。進捗トラッキングやonReady・onStateChangeによる制御は引き続きAPIのアタッチ後に有効になるが、黒画面のまま止まるのは表示側の問題なので解消する。

Q5早送り防止や視聴進捗の送信ロジックには影響しますか?

影響しない。pollTimerとheartbeatTimerを使った早送り防止・進捗送信のロジックはattachPlayerが呼ばれた後に動く点は変わっておらず、変更はYT.Playerの初期化対象をdivからiframeのidに変えただけ。

確認した環境

  • Next.js 16.2.10 / React 19.2.4
  • 2026-07-30 に修正

この記事の根拠

  • TypeScriptファイル 137〜163行目コミット ebc340e
  • TypeScriptファイル 234〜238行目コミット ebc340e
  • TypeScriptファイル 5〜9行目コミット 26784ba
  • TypeScriptファイル 124〜146行目コミット 26784ba
  • TypeScriptファイル 184〜196行目コミット 26784ba

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。