JavaScript, Reactで作る自前のビデオプレイヤー

# はじめに
この記事は、個人開発の際に得た知識を、いつでも見返せるように保存する目的で使用しているため、言葉足らずかつ不適切である箇所が多々あるかと思います。気付いた箇所がありましたら、ご一報いただけると幸いです。

Webアプリケーションにビデオプレイヤーを内包する場合、既存の素晴らしいモジュールの使用をファーストオプションとして考えるかと思います。ですが、今回は学習も兼ねて、敢えて自前で拵えたいと思います。

# 前提
- Node.jsのインストールが済んでいる
- [Material UI](https://mui.com/material-ui/getting-started/installation/)のインストールが済んでいる

Material UIは必須ではないのですが、動画再生ボタンやシークバーなどに使用します。それら自体も自作することはもちろん可能ですが、本筋から逸れる恐れがあるので既存のパッケージを利用させていただきます。

適当なディレクトリを用意し、その中で開発を進めていきます。ディレクトリ名は任意です。`create-react-app`を使用するのもよしですが、自分で設定を記述するのも良いでしょう。その際は以下のリンクを参照してみてください。

https://qiita.com/Sanma-Suki-man/items/1cc50ac075fb9d59d6e7

:::note info
Webpackを用いて動画を埋め込む場合、[`file-loader`](https://www.npmjs.com/package/file-loader)の取得・設定が必要です。
:::

# 作成したい物
- 再生される動画とシークバー、経過時間が連動している
- シークバーを動かすことで、動画の経過時間を動的に変化できる
- 動画の再生 / 停止とボタンを連動させる

それでは実装を開始していきます。

# 実装
一つのファイルにまとめると冗長になり、管理しにくいためいくつかのファイルに分けて実装したいと思います。また作成時に利用させていただいた動画ファイル`cat.mp4`は[Pixabay](https://pixabay.com/ja/videos/)から取得した物です。猫は良い。。。

## 再生 / 停止ボタン
まずはボタン関連の実装を行います。親コンポーネントから、動画が再生中であるか否かの`isPlaying`と動画再生 / 停止をコントロールする`handlePlayPause`を受け取ります。スキップボタンも見栄えをよくするために実装に加えていますが、機能自体は持たせていません。必要に応じて機能拡張も可能です。
```javascript:buttons.js
import React from "react"
import Stack from "@mui/material/Stack";
import IconButton from "@mui/material/IconButton";
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import SkipNextIcon from '@mui/icons-material/SkipNext';
import SkipPreviousIcon from '@mui/icons-material/SkipPrevious';
import PauseIcon from '@mui/icons-material/Pause';

export default function PlayPauseButtons(props) {
  const {isPlaying, handlePlayPause} = props

  return (
    <Stack direction="row" alignItems="center">
      <IconButton><SkipNextIcon /></IconButton>
      {isPlaying ?
        <IconButton onClick={handlePlayPause}><PauseIcon /></IconButton> :
        <IconButton onClick={handlePlayPause}><PlayArrowIcon /></IconButton>
      }
      <IconButton><SkipPreviousIcon /></IconButton>
    </Stack>
  )
}
```

## シークバー
続いて、シークバーの実装です。動画の現在時間(`currentInSec`)、動画長(`durationInSec`)そしてシークバーと動画の現在時間を連動させるために`handleSeekbarValue`を親コンポーネントから受け取ります。現在時間と動画長は秒単位で受け取るため、分と秒に分割するための関数`sec2min`と、数字を指定桁数まで0埋めする関数`zeroPadding`も実装します。
```javascript:seekbar.js
import React from "react"
import Stack from '@mui/material/Stack'
import Slider from "@mui/material/Slider"

export default function Seekbar(props) {
  const {currentInSec, handleSeekbarValue, durationInSec} = props
  const current = sec2min(currentInSec)
  const duration = sec2min(durationInSec)

  function sec2min(sec) {
    const min = Math.floor(sec / 60)
    const secrem = Math.floor(sec % 60)
    return {
      min: min,
      sec: secrem,
    }
  }

  function zeroPadding(num, len) {
    if (len < num.toString().length) {
      console.error('指定桁数よりも大きな数字が入力されました。')
      return num
    }
    return (Array(len).join('0') + num).slice(-len)
  }

  return (
    <Stack spacing={2} direction="row" alignItems="center">
      <p>{current.min}:{zeroPadding(current.sec, 2)} / {duration.min}:{zeroPadding(duration.sec, 2)}</p>
      <Slider
        value={currentInSec}
        min={0}
        max={durationInSec}
        onChange={handleSeekbarValue}
        step={0.2}
      />
    </Stack>
  )
}
```

## メインコンポーネント
先ほどまでで作成したコンポーネントをインポートしつつ、その他必要な機能を実装します。メインコンポーネントはReactのstate変数として、動画が再生中か否か(`isPlaying`)・動画の現在時間(`currentInSec`)・動画長(`durationInSec`)を持ちます。次にReact.useRefを用いて動画要素に紐付けます(`videoRef`)。これにより動画の再生 / 停止や属性値などを取得しやすくなります。

```javascript:videoplayer.js
import React from "react"
import PlayPauseButtons from "./buttons"
import Seekbar from "./seekbar"

import video from '../../assets/cat.mp4'

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = React.useState(false)
  const [currentInSec, setCurrentInSec] = React.useState(0)
  const [durationInSec, setDurationInSec] = React.useState(0)

  const videoRef = React.useRef(null)

  function handlePlayPause() {
    isPlaying ? videoRef.current.pause() : videoRef.current.play()
    setIsPlaying(!isPlaying)
  }

  function handleLoadedMetadata() {
    if (videoRef.current) {
      console.log(`ビデオの長さ: ${videoRef.current.duration}秒`)
      setDurationInSec(videoRef.current.duration)
    } else {
      console.error('ビデオのメタデータを取得不能')
    }
  }

  function handleSeekbarValue(event) {
    setCurrentInSec(event.target.value)
    videoRef.current.currentTime = event.target.value
  }

  React.useEffect(function() {
    document.title="Home made video player"
  }, [])
  
  React.useEffect(function() {
    if (isPlaying) {
      const interval = setInterval(function() {
          setCurrentInSec(videoRef.current.currentTime)
      }, 200)
      return function() {clearInterval(interval)}
    }
  }, [isPlaying])

  return (
    <div className="m-4">
      <video
        ref={videoRef}
        src={video}
        width="100%"
        onClick={handlePlayPause}
        onLoadedMetadata={handleLoadedMetadata}
      />
      <PlayPauseButtons
        isPlaying={isPlaying}
        handlePlayPause={handlePlayPause}
      />
      <Seekbar
        currentInSec={currentInSec}
        handleSeekbarValue={handleSeekbarValue}
        durationInSec={durationInSec}
      />
    </div>
  )
}
```

メインコンポーネントの実装を少し細かく見ていきたいと思います。

### `handleLoadedMetadata()`
`video`タグの`onLoadedMetadata`属性は、動画のメタデータの読み込みが完了したときに実行する関数を与えてことができます。メタデータの完了を待たずに`video`要素にアクセスすると`videoRef.current.duration = NaN`が返され、値が正しく取得できません。
```javascript:videoplayer.js
  function handleLoadedMetadata() {
    if (videoRef.current) {
      console.log(`ビデオの長さ: ${videoRef.current.duration}秒`)
      setDurationInSec(videoRef.current.duration)
    } else {
      console.error('ビデオのメタデータを取得不能')
    }
  }
  ...
        <video
        ref={videoRef}
        src={video}
        width="100%"
        onClick={handlePlayPause}
        onLoadedMetadata={handleLoadedMetadata}
      />
```

### `React.useEffect(...)` × 2
React.useEffect()はレンダー後に実行される関数であり、依存関係を与えてやることで発火条件を制御することができる物です。
前者は初回レンダー時のみ実行され、HTMLのタイトルを変更します。
後者は`isPlaying`が変化した時のみ実行され、内部で繰り返し処理を行う`setInterval()`関数を用いて毎0.2秒ごとに動画の現在時刻を取得します。現在時刻は`currentInSec`の更新に用いられ、シークバーの見た目にも変化が伝播します。
```javascript:videoplayer.js
  React.useEffect(function() {
    document.title="Home made video player"
  }, [])
  
  React.useEffect(function() {
    if (isPlaying) {
      const interval = setInterval(function() {
        setCurrentInSec(videoRef.current.currentTime)
      }, 200)
      return function() {clearInterval(interval)}
    }
  }, [isPlaying])
```

以上が実装の全てです。

# 作成したもの
本記事の締めとして、作成したものを確認します。
![cat.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3626697/9adf3268-3274-6b97-af7d-dee22b82266d.gif)

以上です。お読みいただきありがとうございました。