🎥

ReactでYoutubeを再生しシークバーの移動を検知する

に公開

はじめに

こんにちは!PortalKeyの渋谷です。

今回はプロジェクトでYoutubeを再生する必要が出てきたため、調べてみました。
シークバー操作を検知したかったのですがまさかのイベントが存在せず…
気合で実装した結果を記そうと思います。

開発環境

  • TypeScript v5.5.4
  • react v18.3.1
  • tailwindcss v3.4.10

とりあえず再生してみる

初期化周りをしっかりやってくれてそうなのと、公式APIで出来る事が大体そのまま使えそうなのでこいつを採用することにしました。
https://www.npmjs.com/package/youtube-player

import { useEffect, useRef } from "react"
import createYoutubePlayer from "youtube-player"
import { YouTubePlayer } from "youtube-player/dist/types"

export interface YoutubePlayerTestProps {}

export const YoutubePlayerTest = ({}: YoutubePlayerTestProps) => {
  const videoPlayerContainerRef = useRef<HTMLDivElement>(null)
  const youtubePlayerRef = useRef<YouTubePlayer | null>(null)

  useEffect(() => {
    if (videoPlayerContainerRef.current == null) {
      return
    }

    const player = document.createElement("div")
    player.style.width = "100%"
    player.style.height = "100%"
    videoPlayerContainerRef.current.appendChild(player)

    const youtubePlayer = createYoutubePlayer(player)
    youtubePlayerRef.current = youtubePlayer
    let isDestroyed = false
    youtubePlayer.on("ready", async () => {
      if (isDestroyed) {
        return
      }

      await youtubePlayer.mute()
      await youtubePlayer.loadVideoById("dQw4w9WgXcQ")
      await youtubePlayer.playVideo()
    })

    return () => {
      isDestroyed = true
      youtubePlayerRef.current = null
      player.hidden = true

      const destroy = async () => {
        await youtubePlayer.destroy()
        player.remove()
      }
      void destroy()
    }
  }, [])

  return <div className="w-[512px] aspect-video" ref={videoPlayerContainerRef} />
}

createYoutubePlayerに指定したdivはdestroyした時に一緒に消えてしまうので、同じタイミングでdiv要素をcreateElementするのがいい気がします。
readyのタイミングでmuteにしておかないと特定環境下で再生が始まらないケースがあるようです。(サイト開いて勝手に大音量再生は心臓に悪いですからね…)

左上にこれが出てきました。操作もできて安心。

シークバーの移動検知

さて、ここからが本題です。ユーザーがプレイヤー上での操作を検知してみましょう。

今回検知できたのは以下のイベントです。

  • 一時停止した
  • 再生した
  • シークバーを移動した
  • 一時停止中にシークバーを移動した
  • ループ再生された

今回の検知方法でシークバー移動を検知するには条件が1つあります。

    const youtubePlayer = createYoutubePlayer(player, {
      playerVars: {
        disablekb: 1
      }
    })

youtubePlayerの設定でキーボード操作を無効化してください。
キーボード操作によるシークバー移動では、後述するchangeStateイベントが呼ばれません…

import { useEffect, useRef } from "react"
import createYoutubePlayer from "youtube-player"
import PlayerStates from "youtube-player/dist/constants/PlayerStates"
import { YouTubePlayer } from "youtube-player/dist/types"

export interface YoutubePlayerTestProps {}

export const YoutubePlayerTest = ({}: YoutubePlayerTestProps) => {
  const videoPlayerContainerRef = useRef<HTMLDivElement>(null)
  const youtubePlayerRef = useRef<YouTubePlayer | null>(null)
  const prevTimeRef = useRef<number | null>(null)
  const pausedTimeRef = useRef<number | null>(null)

  useEffect(() => {
    if (videoPlayerContainerRef.current == null) {
      return
    }

    const player = document.createElement("div")
    player.style.width = "100%"
    player.style.height = "100%"
    videoPlayerContainerRef.current.appendChild(player)

    const youtubePlayer = createYoutubePlayer(player, {
      playerVars: {
        disablekb: 1
      }
    })
    youtubePlayerRef.current = youtubePlayer
    let isDestroyed = false
    youtubePlayer.on("ready", async () => {
      if (isDestroyed) {
        return
      }

      await youtubePlayer.mute()
      await youtubePlayer.loadVideoById("dQw4w9WgXcQ")
      await youtubePlayer.playVideo()
    })

    youtubePlayer.on("stateChange", async (event) => {
      const checkSeekBar = (currentTime: number) => {
        if (pausedTimeRef.current == null) {
          return
        }
        if (youtubePlayerRef.current == null) {
          return
        }

        // ポーズタイミングのPlayerの時間と今のPlayerの時間の差分でただの停止だったかシークバーの移動だったかを判定
        const isMoveSeekBar = Math.abs(currentTime - pausedTimeRef.current) > 1
        if (isMoveSeekBar) {
          console.log("シークバーが動かされたよ! (%d -> %d)", pausedTimeRef.current, currentTime)
        } else {
          console.log("ただの再生開始だよ! (%d -> %d)", pausedTimeRef.current, currentTime)
        }
      }

      switch (event.data) {
        case PlayerStates.PAUSED as number:
        case PlayerStates.BUFFERING as number:
          // シークバーに触る or 一時停止 or 広告 or 読み込み で設定
          pausedTimeRef.current = prevTimeRef.current
          break
        case PlayerStates.PLAYING as number:
          if (youtubePlayerRef.current != null) {
            const currentTime = await youtubePlayerRef.current.getCurrentTime()
            if (pausedTimeRef.current != null) {
              checkSeekBar(currentTime)
            } else {
              if (Math.floor(currentTime) === 0) {
                prevTimeRef.current = 0

                console.log("リスタートボタンが押されたか、ループ設定で再生が開始されたよ!")
              }
            }
          }

          pausedTimeRef.current = null
          break
        case PlayerStates.ENDED as number:
          if (youtubePlayerRef.current != null) {
            // ENDED時はgetCurrentTimeが正しい値を返さないので、getDurationを使う
            const currentTime = await youtubePlayerRef.current.getDuration()
            checkSeekBar(currentTime)
          }

          pausedTimeRef.current = null
          break
      }
    })

    return () => {
      isDestroyed = true
      youtubePlayerRef.current = null
      player.hidden = true

      const destroy = async () => {
        await youtubePlayer.destroy()
        player.remove()
      }
      void destroy()
    }
  }, [])

  useEffect(() => {
    const tick = async () => {
      if (youtubePlayerRef.current == null) {
        return
      }

      const currentTime = await youtubePlayerRef.current.getCurrentTime()
      prevTimeRef.current = currentTime

      // 一時停止中にシークバーの移動があった時用対応
      const state = await youtubePlayerRef.current.getPlayerState()
      if (state === PlayerStates.PAUSED && pausedTimeRef.current != null) {
        const currentTime = await youtubePlayerRef.current.getCurrentTime()
        if (Math.abs(currentTime - pausedTimeRef.current) > 1) {
          console.log("一時停止中にシークバーが動かされたよ! (%d -> %d)", pausedTimeRef.current, currentTime)
          pausedTimeRef.current = currentTime
        }
      }
    }

    void tick()
    const intervalId = setInterval(tick, 1000)

    return () => {
      clearInterval(intervalId)
    }
  }, [])

  return <div className="w-[512px] aspect-video" ref={videoPlayerContainerRef} />
}


基本はstateChangeイベントを用いて検知を行います。
再生中のシークバー移動は
PLAYING -> PAUSE -> (BUFFERING) -> PLAYING
の順番で呼ばれるのでPAUSEタイミングで直近の再生時間を記録し、その差分を取ることでシークバー移動かどうかを検知できます。

一時停止中はstateChangeイベントが呼ばれないので1秒毎に一時停止時の時間と比較して検知を行っています。

補足

キーボードを有効化して検知を行いたい場合はstateChangeで行うのではなく、tick関数内で頑張って検知を行うしか無いと思います。tick内で検知以外の処理をしないのであれば、それで問題ないはずです。

自分はtick関数内で他の処理を呼んでいて、その処理と検知がどうあがいても干渉してしまうため仕方なくキーボード操作を塞ぎ、現在の形で落ち着きました。

最後に

いかがでしたでしょうか?
再生、停止などシンプルな閲覧だけであれば用意されたもので簡単に実装ができるのですが、それを超えた実装をしようとすると一気に大変になるなぁといった印象です…。
(正直シークバーの移動はイベントで用意して欲しい…)

PortalKey Tech Blog

Discussion