React で video.js を使ってみた

React で video.js を使ってみたときの作業メモです。バージョンは以下の通りです。

```
"video.js": "^7.11.8",
"videojs-errors": "^4.5.0",
"@types/video.js": "^7.3.27",
"@types/videojs-errors": "^4.5.0"
```

## ひな形

[Video.js and ReactJS integration](https://docs.videojs.com/tutorial-react.html) でひな形をつくります。

## コントロールバーのカスタマイズ

コントロールバーの各種ボタン（再生ボタンや全画面表示ボタン）の表示/非表示を実現する素朴な方法です。
まずはそれぞれの名前を調べます。`player.controlBar.children()` で一覧表示すると便利です。

```.ts
console.log("player.controlBar.children()::", player.controlBar.children());
```

独自のボタンも作れます。

```.ts
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js";
const Button = videojs.getComponent("Button");

// Bootstrap Icons あたりから取得しました
const iconSVG = `<div style="cursor: pointer"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-square" viewBox="0 0 16 16">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/>
</svg></div>`;

interface MyButtonOption extends VideoJsPlayerOptions {
	onClick: () => void;
}

export default class MyButton extends Button {
	constructor(player: VideoJsPlayer, options: MyButtonOption) {
		super(player, options);
		this.controlText("My Button");
		this.el().innerHTML = iconSVG;
	}

	handleClick() {
		const player = this.player();
		player.pause(); // など、このボタンでやりたいことを書く。
	}

	createEl(): HTMLButtonElement {
		return super.createEl("button", {
			className: "vjs-custom",
		});
	}
}
```

独自のボタンは、videojs.registerComponent で登録する必要があります。

```.ts
  React.useEffect(() => {
    // make sure Video.js player is only initialized once
    if (!playerRef.current) {
      const videoElement = videoRef.current;
      if (!videoElement) return;

      videojs.registerComponent("Myutton", MyButton);  // これです
```

その後、デフォルトのコントロールバーのコンポーネントをいったん削除し、表示したい順で追加します。

```.ts
const pictureInPictureToggle = player.controlBar.getChild("PictureInPictureToggle");
const fullscreenToggle = player.controlBar.getChild("FullscreenToggle");
const currentTimeDisplay = player.controlBar.getChild("CurrentTimeDisplay");
const volumePanel = player.controlBar.getChild("VolumePanel");
const durationDisplay = player.controlBar.getChild("DurationDisplay");
const progressControl = player.controlBar.getChild("ProgressControl");
const remainingTimeDisplay = player.controlBar.getChild("RemainingTimeDisplay");
player.controlBar.removeChild(volumePanel);
player.controlBar.removeChild(durationDisplay);
player.controlBar.removeChild(progressControl);
player.controlBar.removeChild(pictureInPictureToggle);
player.controlBar.removeChild(fullscreenToggle);
player.controlBar.removeChild(currentTimeDisplay);
player.controlBar.removeChild(remainingTimeDisplay);
player.controlBar.addChild("MyButton");
player.controlBar.addChild("VolumePanel");
player.controlBar.addChild("DurationDisplay");
player.controlBar.addChild("ProgressControl");
player.controlBar.addChild("CurrentTimeDisplay");
player.controlBar.addChild("FullscreenToggle");
```

もっと高度なカスタマイズは、[公式ドキュメント](https://docs.videojs.com/tutorial-components.html#creating-a-component) や、個人ブログ [How to merge Video.js buttons into one](https://www.tutorialguruji.com/javascript/how-to-merge-video-js-buttons-into-one/) が参考になるはずです。


## TypeError: Cannot read properties of null (reading 'currentTime') 対策

CRA で試しているときは問題なかったのですが、大きめのアプリに組み込むと `TypeError: Cannot read properties of null (reading 'currentTime')` というエラーが発生し、悩まされました。
後処理に `player.controlBar.progressControl.seekBar.dispose()` を追加することで、解消しました。

```.ts
useEffect(() => {
    const player = playerRef.current;
    
    return () => {
    	if (player) {
    		player.dispose();
    		player.controlBar.progressControl.seekBar.dispose(); // TypeError: Cannot read properties of null (reading 'currentTime') 対策
    		playerRef.current = null;
    	}
    };
}, [playerRef]);
```

## 異常系の実装

video.js の [MediaError](https://docs.videojs.com/mediaerror) だけでは、補足できないエラーがあります。[videojs-errors](https://github.com/brightcove/videojs-errors) というプラグインが便利でした。videojs() の第3引数のコールバック内で設定を行う必要があります。

```.ts
const player: AppPlayer = (playerRef.current = videojs(
    videoElement,
    options,
    () => {
    	player.errors(); // videojs-errors の有効化
    	player.errors.timeout(10 * 1000); // タイムアウトの設定を追加
    	onReady && onReady(player);
    }
));
```

ただ、VideoJsPlayer の @types/videojs-errors による拡張がうまくいっていないようでした。player.errors() の errors() が生えません。以下のような残念な対応で乗り切りました。

```.ts
// import * as errors from "videojs-errors";

interface AppPlayer extends VideoJsPlayer {
	//errors: typeof errors; // これをワークアラウンドとしたかったが、errors() がない。
	errors: any;
}
```

処理自体は player の error イベントのリスナに書いてゆきます。

```.ts
player.on("error", (e) => {
    console.log("player.error()::", player.error());
});
```

タイムアウトについては、他に `player.tech` の `retryplaylist` のリスナとして自前実装する案もありましたが、採用しませんでした。

```.ts
player.on("loadedmetadata", () => {
    let retries = 0;
    player
    	.tech({ IWillNotUseThisInPlugins: true })
    	.on("retryplaylist", (e) => {
    		retries++;
    		if (retries >= 5) {
    			e.stopImmediatePropagation();
                player.dispose()
    		}
    	});
});
```

## 開発環境構築とデータの準備

フリー動画。

- [pixaboy > 高品質なフリー動画素材](https://pixabay.com/ja/videos/)

このへんを参考に手元のライブストリーミング環境をつくりました。

- [HLSライブストリーミング配信環境を最速で立ち上げる](https://qiita.com/Silphire/items/91c69790752c6a0314c7)
- [RTMP to MPEG-DASH でライブストリーミングを行う方法](https://qiita.com/nikaera/items/f46d45bc1957a71b42e4)

次のようなデータもみつけました。

- [Free HLS m3u8 URLs for Testing HLS Players [Updated]](https://ottverse.com/free-hls-m3u8-test-urls/)
- [Free MPEG-DASH MPD (manifest) Examples for Testing](https://ottverse.com/free-mpeg-dash-mpd-manifest-example-test-urls/)

ご参考。

- ライブ版 HLS(application/x-mpegURL): https://cph-p2p-msl.akamaized.net/hls/live/2000341/test/master.m3u8
- ライブ版 DASH(application/dash+xml): https://livesim.dashif.org/livesim/chunkdur_1/ato_7/testpic4_8s/Manifest.mpd
- VOD 版 HLS(application/x-mpegURL): https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8
- VOD 版 DASH(application/dash+xml): https://s3.amazonaws.com/_bc_dml/example-content/sintel_dash/sintel_vod.mpd