React + TypeScript + react-player で表示領域の横幅に応じた動画の縦幅の自動調整

## はじめに

複数名の配信者による Minecraft や ARK: Survival Evolved などのいわゆる箱企画を、個人的に良い感じで視聴できるWebアプリが欲しいなと思い、React + TypeScript + react-player で多窓プレイヤーを作成し始めました。

以前にホロライブの配信予定を閲覧する Web アプリを作成したので、その配信予定（過去配信含む）を一覧表示し、選択した配信をマス目上に配置、そこから選択した配信をピックアップして視聴するイメージで作成していきます。

まずは、ブラウザのサイズ変更に応じて動画の高さを自動調整する React 関数コンポーネントを作成します。

## 開発環境

* Windows 11 23H2
* Visual Studio 2022 17.10.4
* Node 20.11.0 + npm 10.2.4
* React 18.2.0 + React Router 6.22.0
* Chakra UI/React 2.8.2 + Chakra UI/Icons 2.1.1
* TypeScript 5.2.2

## 利用・参考にした自作アプリ

* ホロジューラー（[VS2022 17.8 の React テンプレート (Vite) でホロジュール Web アプリ](https://qiita.com/kerobot/items/3cf5035380c8e17690bb)）
* ホロサービス（[ホロジュール収集バックエンド API の FastAPI 化](https://qiita.com/kerobot/items/28b109c8c5736a692b95)）
* ホロコレクト（[ホロジュール収集プログラム（Python）のアップデート](https://qiita.com/kerobot/items/c1944dc29d6de542ad86)）

## 動画の表示方法の検討

まずは \<iframe\> タグの src に動画の URL を指定し、ブラウザサイズの変更に応じて 16:9 の比率から高さを計算できたのですが、再生準備完了(Ready)イベントを拾うことができず別の方法へ切り替えました。

[YouTube 埋め込みプレーヤーとプレーヤーのパラメータ](https://developers.google.com/youtube/player_parameters?hl=ja)

次は、YouTube Player API ではなく、react-youtube ライブラリを試してみました。今度は再生準備完了(Ready)イベントを拾うことができましたが、ブラウザサイズの変更に応じて 16:9 の比率から計算した高さをうまく適用することができずこちらもあきらめました。

[react-youtube](https://github.com/tjallingt/react-youtube)

その次は react-player ライブラリを試してみました。ネットの情報を参考に高さの調整を CSS にまかせてみましたが、あくまでも表示領域の高さに依存した調整となってしまうため、この方法もあきらめました。

[react-player](https://github.com/cookpete/react-player)

![スクリーンショット 2024-08-15 191333.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/58a8a235-b290-28e8-80eb-481aeaed79f7.png)

その際のコードはこのような感じです。

```css
.video-wrapper {
    height: 100%;
    width: 100%;
    min-height: 225px;
}

.player-wrapper {
    width: auto;
    height: auto;
}

.react-player {
    padding-top: 56.25%;
    position: relative;
}

    .react-player > div {
        position: absolute;
    }
```

```typescript
return (
    <div className="video-wrapper">
        <ReactPlayer
            width="100%"
            height="100%"
            url={videoSrc}
            playing={playing}
            muted={muted}
            controls
        />
    </div>
);
```

## 動画の表示方法の決定

紆余曲折あり、結果として react-player ライブラリといくらかの処理を組み合わせることで、再生準備完了(Ready)イベントを拾い、ブラウザサイズの変更に応じて 16:9 の比率から高さを更新するようにできました。

![スクリーンショット 2024-08-15 191318.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/74320eda-cda9-b90b-2e86-b50514aaa8af.png)

```typescript
import { FC, useState, useEffect, useCallback, useRef } from "react";
import ReactPlayer from 'react-player/youtube'

type YoutubePlayerProps = {
    videoId: string;
    playing?: boolean;
    muted?: boolean;
}

export const YoutubePlayer: FC<YoutubePlayerProps> = (props) => {

    const { videoId, playing = false, muted = true } = props;
    // 埋め込む動画のURL
    const videoSrc = `https://www.youtube.com/embed/${videoId}`;
    // 動画プレイヤーの高さの初期値
    const defaultHeight = 0;
    // 動画プレイヤーの高さを保持
    const [videoHeight, setVideoHeight] = useState<number>(defaultHeight);
    // 動画プレイヤーの参照を保持
    const playerRef = useRef<ReactPlayer>(null);

    // 動画プレイヤーの参照から辿った iFrame の横幅に応じて高さを計算（0.5625 = 16:9）
    const getVideoHeight = () => {
        const iframe = playerRef.current?.getInternalPlayer()?.getIframe();
        return iframe ? iframe.offsetWidth * 0.5625 : defaultHeight;
    }

    // 動画プレイヤーの高さを更新する
    const calculateVideoHeight = () => {
        return setVideoHeight(getVideoHeight());
    }

    // 動画プレイヤーの横幅が変更されたときに高さを再計算するためのコールバック関数
    // useCallback でメモ化しているが、依存配列を[]としているため初回レンダリング時に定義される
    const handleChangeVideoWidth = useCallback(() => {
        return calculateVideoHeight();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    // ウィンドウ幅の変更に応じて呼び出すイベントリスナーを追加する
    // useEffect の依存配列を[]としているため初回レンダリング時のみ実行される
    useEffect(() => {
        window.addEventListener("resize", handleChangeVideoWidth);
        return () => window.removeEventListener("resize", handleChangeVideoWidth);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    // 動画の準備が完了した際に高さを計算しておく
    const onReady = () => {
        calculateVideoHeight();
    }

    return (
        <ReactPlayer
            ref={playerRef}
            onReady={onReady}
            width="100%"
            height={`${videoHeight}px`}
            url={videoSrc}
            playing={playing}
            muted={muted}
            controls
        />
    );
};
```

### 動画の表示について

ReactPlayer コンポーネントの Props（コンポーネントに対するパラメーター）をいろいろと設定しています。再生する動画ID、再生状態、ミュート状態をコンポーネント外から指定可能としました。

```ref = {playerRef}``` として、事前に宣言した ```const playerRef = useRef<ReactPlayer>(null);``` を指定し、ReactPlayer のインスタンスをロジックから参照できるようにしています。

また、```height={`${videoHeight}px`}``` として、ロジックで計算した高さを反映するようにしています。

```typescript
return (
    <ReactPlayer
        ref={playerRef}
        onReady={onReady}
        width="100%"
        height={`${videoHeight}px`}
        url={videoSrc}
        playing={playing}
        muted={muted}
        controls
    />
```

:::note info
useRef により要素の参照を保持することができます。
また、useState と異なり、再レンダリングが発生しません。
:::

### 動画の高さ計算について

```getVideoHeight()``` では、```playerRef.current?.getInternalPlayer()?.getIframe()``` で ReactPlayer の参照から内部プレーヤーの iFrame を取得し、iFrame の外側のサイズを ```iframe.offsetWidth``` で取得したうえで、16:9 となるように 0.5625 を掛けて、高さを計算しています。

なお、iFrame を取得できない場合はデフォルトで高さを 0 としています。

```typescript
const getVideoHeight = () => {
    const iframe = playerRef.current?.getInternalPlayer()?.getIframe();
    return iframe ? iframe.offsetWidth * 0.5625 : defaultHeight;
}
```

```calculateVideoHeight()``` では、計算した高さを ```const [videoHeight, setVideoHeight] = useState<number>(defaultHeight);``` と宣言したステートに設定することで、再レンダリングが行われ、ReactPlayer の高さが更新されます。

```typescript
const calculateVideoHeight = () => {
    return setVideoHeight(getVideoHeight());
}
```

:::note info
useState によりコンポーネントでステート(状態)を保持することができます。また、状態が更新されると、再レンダリングが発生します。
:::

### 動画の高さ更新について

ウィンドウサイズが変更された際に、高さを再計算するためのコールバック関数を用意しておきます。

```typescript
const handleChangeVideoWidth = useCallback(() => {
    return calculateVideoHeight();
}, []);
```

:::note info
useCallback によりコールバック関数をメモ化することで、関数インスタンスの作成を抑制します。なお、依存配列を空にした場合は、初回レンダリング時のみ関数を定義します。
:::

ウィンドウサイズが変更されたレンダリング後に、高さを再計算するためのコールバック関数を登録します。アンマウント時の処理としてコールバック関数の解除も行っています。

```typescript
useEffect(() => {
    window.addEventListener("resize", handleChangeVideoWidth);
    return () => window.removeEventListener("resize", handleChangeVideoWidth);
}, []);
```

:::note info
useEffect により関数の実行タイミングをレンダリング後まで遅らせます。なお、依存配列を空にした場合は、初回レンダリング時のみ副作用関数が実行されます。
:::

動画の準備が完了した  ```onReady()``` のタイミングで高さを計算しておきます。

```typescript
const onReady = () => {
    calculateVideoHeight();
}
```

## おわりに

今回はブラウザのサイズ変更に応じてピックアップした動画のサイズを調整するコンポーネントを作成しました。引き続きスケジュールの検索や動画の一覧表示などアプリ全体の実装などを進めます。