React + TypeScript ホロジュール Web アプリに多窓再生機能を追加

## はじめに

Visual Studio 2022 の React テンプレート (Vite) を利用して開発したホロジュール Web アプリをベースに、多窓再生機能を追加し、別ウィンドウ表示とすることでスクリーンショットの Chrome 拡張機能も利用可能にしました。

前回のホロジュール Web アプリに下記を組み合わせたものとなります。

https://qiita.com/kerobot/items/1f22e2ee3c4f8236a0d4

https://qiita.com/kerobot/items/886ae7ed6598d763b8bf

## 作ったもの

複数名の配信者による Minecraft や ARK: Survival Evolved や GTA などのいわゆる箱企画を、個人的に良い感じで視聴できる Web アプリです。

![img01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/ca867e8a-dad6-a601-dcb0-995527390c7c.png)

https://github.com/kerobot/holoduler2024

## バックエンド

ホロライブプロダクション配信予定スケジュール『ホロジュール』のWebページを定期的にスクレイピングし、配信者や配信予定の情報を取得して MongoDB に格納する「ホロクローラー」と、MongoDB に格納した配信者や配信予定の情報を提供する「ホロサービス」をバックエンドとして利用しています。

https://qiita.com/kerobot/items/c1944dc29d6de542ad86

https://qiita.com/kerobot/items/28b109c8c5736a692b95

## 開発環境

* Windows 11 24H2
* PowerShell 7.4.6
* Visual Studio 2022 17.12.3
* .NET 8
* Node 22.8.0 + npm 10.8.2
* React 18.2.0 + React Router 6.22.0
* Chakra UI/React 2.8.2 + Chakra UI/Icons 2.1.1
* TypeScript 5.5.4

## 起動した際に証明書関連のエラーが発生した場合

起動した際に「There was an error exporting the HTTPS developer certificate to a file.」というエラーが発生することがありました。

下記を参考に、vite-plugin-mkcert を追加してクライアント側の証明書を作成するようにしたところエラーを解消できて助かりました。

https://qiita.com/KM_20005/items/275f27994697451246a3

## 設定ファイル

package.json, vite.config.ts, tsconfig.json, tsconfig.node.json, vite.config.ts は前回のまま変更せずに利用しています。

## サーバー側 (ASP.NET Core Web API)

サーバー側のコントローラーやモデル、サービス、HTTP リクエストファイルなども前回のまま変更せずに利用しています。

## クライアント側 (React)

クライアント側は、これまでのスケジュール表示をサイドバーに移動し、サイドバーのスケジュールをクリックして動画を選択、そのうちのひとつをピックアップして表示できるように改修しました。

以下、変更点をいくつか抜粋します。

### トグルボタンの追加

スケジュールから選択した動画の全再生のON/OFF、全消音のON/OFFを切り替えるためのトグルボタンを追加しました。

holoduler.client\src\components\atoms\ToggleButton.tsx

```typescript
import { FC, useState, useEffect } from "react";
import { Button } from "@chakra-ui/react";

type ToggleButtonProps = {
    onLabel: string;
    offLabel: string;
    initialState?: boolean;
    onToggle: (isToggled: boolean) => void;
};

export const ToggleButton: FC<ToggleButtonProps> = (props) => {
    const { onLabel, offLabel, initialState = false, onToggle } = props;
    const [isToggled, setIsToggled] = useState(initialState);

    useEffect(() => {
        setIsToggled(initialState);
    }, [initialState]);

    const handleClick = () => {
        const newToggledState = !isToggled;
        setIsToggled(newToggledState);
        onToggle(newToggledState);
    };

    return (
        <Button onClick={handleClick} colorScheme={isToggled ? 'red' : 'blue'}>
            {isToggled ? onLabel : offLabel}
        </Button>
    );
};
```

### サイズ可変 Youtube プレーヤーの追加

前回作成した Youtube プレイヤーを組み込みました。ブラウザのウィンドウサイズにあわせて動画のサイズが可変するようにしています。react-player/youtube を利用しています。

holoduler.client\src\components\atoms\YoutubePlayer.tsx

```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
        />
    );
};
```

### スケジュールリストとスケジュールアイテムの修正

配信者や配信予定の情報をサイドバーに表示するため修正しました。

holoduler.client\src\components\molecules\ScheduleList.tsx

```typescript
import { FC } from "react";
import { ScheduleItem } from "./ScheduleItem";
import { Schedule } from "../../types/api/schedule";
import { Text } from "@chakra-ui/react";

type ScheduleListProps = {
    schedules: Schedule[];
    onItemClick: (schedule: Schedule) => void;
}

export const ScheduleList: FC<ScheduleListProps> = (props) => {
    const { schedules, onItemClick } = props;

    if (schedules !== undefined && schedules.length > 0) {
        return (
            <>
                {schedules.map((schedule) => (
                    <ScheduleItem key={schedule.key} schedule={schedule} onItemClick={() => onItemClick(schedule)} />
                ))}
            </>
        );
    } else {
        return <Text fontSize="md" as="b">not exists</Text>;
    }
};
```

holoduler.client\src\components\molecules\ScheduleItem.tsx

```typescript
import { FC } from "react";
import { Box, Image, Text, Link, Grid } from "@chakra-ui/react";

import { Schedule } from "../../types/api/schedule";
import { StreamerHelper } from "../../utils/StreamerHelper";
import { StreamTime } from "../atoms/StreamTime";

type ScheduleItemProps = {
    schedule: Schedule;
    onItemClick: () => void;
}

export const ScheduleItem: FC<ScheduleItemProps> = (props) => {
    const { schedule, onItemClick } = props;

    return (
        <Grid templateRows="1fr auto" height="100%" bg="gray.200" borderWidth='1px' rounded='md' shadow="md" p={2} _hover={{ opacity: 0.8 }}>
            {/* 上部領域 */}
            <Grid templateColumns="1fr 2fr" height="100%">
                {/* 左側領域 */}
                <Grid templateRows="0.4fr 1.0fr 0.6fr" height="100%">
                    {/* 配信時刻 */}
                    <Box display="flex" alignItems="center" justifyContent="center" fontWeight='semibold'>
                        <StreamTime streaming_at={schedule.streaming_at} />
                    </Box>
                    {/* 配信者アイコン */}
                    <Box display="flex" alignItems="center" justifyContent="center">
                        <Link href={StreamerHelper.getChannelUrl(schedule.code)} isExternal>
                            <Image
                                borderRadius="full"
                                boxSize="50px"
                                m="auto"
                                src={StreamerHelper.getImageUrl(schedule.code)}
                                alt={schedule.name} />
                        </Link>
                    </Box>
                    {/* 配信者名 */}
                    <Box display="flex" alignItems="center" justifyContent="center" fontWeight='semibold'>
                        <Text noOfLines={2}>{schedule.name}</Text>
                    </Box>
                </Grid>
                {/* 右側領域 */}
                <Box display="flex" alignItems="center" justifyContent="center">
                    <Link onClick={onItemClick}>
                        <Image src={StreamerHelper.getThumbnailUrl(schedule.video_id)} w="100%" />
                    </Link>
                </Box>
            </Grid>
            {/* 下部領域 */}
            <Box height="60px">
                <Text fontSize="sm" mt="1" noOfLines={3}>{schedule.title}</Text>
            </Box>
        </Grid>
    );
};
```

### Youtube 動画表示の追加

スケジュールリストから選択した配信をページ下部に追加/削除するコンポーネントを追加します。埋め込み Youtube 動画は Chrome 拡張機能の対象にできないため、ボタンクリックで Youtube 動画を別ウインドウ表示し、Chrome 拡張機能の対象となるようにしました。

holoduler.client\src\components\molecules\YoutubeItem.tsx

```typescript
import { FC } from "react";
import { Box, IconButton } from "@chakra-ui/react";
import { CloseIcon, TriangleUpIcon, ExternalLinkIcon } from '@chakra-ui/icons';
import { Schedule } from "../../types/api/schedule";
import { YoutubePlayer } from "../atoms/YoutubePlayer";

type YoutubeItemProps = {
    schedule: Schedule;
    playing: boolean;
    muted: boolean;
    onSelectItem: () => void;
    onRemoveItem: () => void;
}

const openVideoInNewWindow = (videoId: string) => {
    const url = `https://www.youtube.com/embed/${videoId}?rel=0&autoplay=1`;
    window.open(url, undefined, 'width=1280,height=720');
};

export const YoutubeItem: FC<YoutubeItemProps> = (props) => {
    const { schedule, playing, muted, onSelectItem, onRemoveItem } = props;

    return (
        <Box
            bg="gray.200"
            width="426px"
            height="240px"
            position="relative"
            display="flex"
            alignItems="center"
            justifyContent="center"
        >
            <YoutubePlayer videoId={schedule.video_id} playing={playing} muted={muted} />
            <Box position="absolute" top="2px" left="2px" display="flex" gap="4px">
                <IconButton
                    icon={<TriangleUpIcon />}
                    onClick={() => onSelectItem()}
                    aria-label="Select"
                    size="sm"
                />
                <IconButton
                    icon={<ExternalLinkIcon />}
                    onClick={() => openVideoInNewWindow(schedule.video_id)}
                    aria-label="Open"
                    size="sm"
                />
            </Box>
            <Box position="absolute" top="2px" right="2px">
                <IconButton
                    icon={<CloseIcon />}
                    onClick={() => onRemoveItem()}
                    aria-label="Close"
                    size="sm"
                />
            </Box>
        </Box>
    );
};
```

### 表示領域の追加

ここまで作成したコンポーネントを配置するために、サイドバー、トップエリア、ミドルエリアの表示領域を追加しました。

holoduler.client\src\components\organisms\Sidebar.tsx

```typescript
import { FC } from "react";
import { ScheduleList } from "../molecules/ScheduleList";
import { Schedule } from "../../types/api/schedule";
import { VStack, Box, Spinner } from "@chakra-ui/react";

interface SidebarProps {
    loading: boolean;
    schedules: Schedule[];
    onScheduleSelected: (item: Schedule) => void;
}

export const Sidebar: FC<SidebarProps> = (props) => {
    const { loading, schedules, onScheduleSelected } = props;

    return (
        <Box
            w="340px" // サイドバーの幅
            h="calc(100vh - 60px)" // ヘッダーの高さを引いた高さ
            overflowY="auto" // 縦方向にスクロール可能
            borderColor="gray.200"
            p="4"
        >
            {loading ? (
                <VStack align="center" justify="center" h="100%">
                    <Spinner
                        thickness='4px'
                        speed='0.65s'
                        emptyColor='gray.200'
                        color='blue.500'
                        size='xl' />
                </VStack>
            ) : (
                <VStack align="start" spacing="2">
                    <ScheduleList schedules={schedules} onItemClick={onScheduleSelected} />
                </VStack>
            )}
        </Box>
    );
};
```

holoduler.client\src\components\organisms\TopArea.tsx

```typescript
import { FC } from "react";
import { Stack, Heading } from "@chakra-ui/react";
import { Schedule } from "../../types/api/schedule";
import { YoutubePlayer } from "../atoms/YoutubePlayer";

type TopAreaProps = {
    schedule: Schedule;
    playing: boolean;
    muted: boolean;
};

export const TopArea: FC<TopAreaProps> = (props) => {
    const { schedule, playing, muted } = props;

    return (
        <Stack spacing={0}>
            <Heading size="md" mb="1">{schedule.title}</Heading>
            <YoutubePlayer videoId={schedule.video_id} playing={playing} muted={muted} />
        </Stack>
    );
};
```

holoduler.client\src\components\organisms\MiddleArea.tsx

```typescript
import { FC } from "react";
import { Stack, Text } from "@chakra-ui/react";
import { ToggleButton } from "../atoms/ToggleButton";

type MiddleAreaProps = {
    allUnmuted: boolean;
    allPlaying: boolean;
    onAllUnmuted: (isToggled: boolean) => void;
    onAllPlaying: (isToggled: boolean) => void;
};

export const MiddleArea: FC<MiddleAreaProps> = (props) => {
    const { allUnmuted, allPlaying, onAllUnmuted, onAllPlaying } = props;

    return (
        <Stack direction={["column", "row"]} spacing="2" alignItems="center" m="1">
            <Text>Selected videos</Text>
            <ToggleButton onLabel="Mute All" offLabel="Unmute All" initialState={allUnmuted} onToggle={onAllUnmuted} />
            <ToggleButton onLabel="Stop All" offLabel="Play All" initialState={allPlaying} onToggle={onAllPlaying} />
        </Stack>
    );
};
```

### ホロジュールコンポーネントの修正

ヘッダー、サイドバー、トップエリア、ミドルエリアを組み合わせてホロジュール Web アプリコンポーネントを修正します。フックは前回のまま利用しています。

holoduler.client\src\scenarios\Holoduler.tsx

```typescript
import { memo, useEffect, FC, useState } from "react";
import { Box, Flex, Wrap, WrapItem } from "@chakra-ui/react";
import { Header } from "../components/organisms/Header";
import { Sidebar } from "../components/organisms/Sidebar";
import { useSchedules } from "../hooks/useSchedules";
import { Schedule } from "../types/api/schedule";
import { TopArea } from "../components/organisms/TopArea";
import { MiddleArea } from "../components/organisms/MiddleArea";
import { YoutubeItem } from "../components/molecules/YoutubeItem";

// 配信予定ページコンポーネント
export const Holoduler: FC = memo(() => {
    const [searchResults, setSearchResults] = useState<Schedule[]>([]);
    const [selectedItems, setSelectedItems] = useState<Schedule[]>([]);
    const [selectedItem, setSelectedItem] = useState<Schedule | null>(null);
    const [allUnmuted, setAllUnmuted] = useState(false);
    const [allPlaying, setAllPlaying] = useState(false);
    const { getSchedules, loading, schedules } = useSchedules();

    // 選択したアイテムをリストに追加（リストに存在しない場合にスプレッド構文を利用してアイテムを追加）
    const handleItemSelected = (item: Schedule) => {
        setSelectedItems((items) => {
            if (items.some((items) => items.key === item.key)) {
                return items;
            }
            return [...items, item];
        });
    };

    // インデックスで指定したアイテムをリストから削除
    const handleItemRemove = (index: number) => {
        setSelectedItems((items) => items.filter((_, i) => i !== index));
    };

    // 検索条件を元にスケジュールを検索
    const handleSearch = (date: Date, group: string, keyword: string) => {
        getSchedules(date, date, group, keyword);
    };

    // スケジュールが取得された際に検索結果を更新
    useEffect(() => {
        setSearchResults(schedules?.schedules || []);
    }, [schedules]);

    return (
        <Flex direction="column" height="100vh">
            {/* ヘッダー（配信の検索） */}
            <Header onSearchSchedule={handleSearch} />
            <Flex flex="1" overflow="hidden">
                {/* サイドバー（配信の一覧表示） */}
                <Sidebar
                    loading={loading}
                    schedules={searchResults}
                    onScheduleSelected={handleItemSelected} />
                <Flex direction="column" flex="1">
                    {/* 上段（ピックアップ動画表示） */ }
                    {selectedItem && (
                        <TopArea
                            schedule={selectedItem}
                            playing={true}
                            muted={false} />
                    )}
                    {/* 中段（再生と音声の制御） */}
                    {selectedItems.length > 0 && (
                        <MiddleArea
                            allUnmuted={allUnmuted}
                            allPlaying={allPlaying}
                            onAllUnmuted={setAllUnmuted}
                            onAllPlaying={setAllPlaying} />
                    )}
                    {/* 下段（動画の一覧表示） */}
                    <Box flex="1" overflowY="auto">
                        <Wrap spacing="2">
                            {selectedItems.map((item, index) => (
                                <WrapItem key={index}>
                                    {/* 動画表示 */}
                                    <YoutubeItem
                                        schedule={item}
                                        playing={allPlaying}
                                        muted={!allUnmuted}
                                        onSelectItem={() => setSelectedItem(item)}
                                        onRemoveItem={() => handleItemRemove(index)} />
                                </WrapItem>
                            ))}
                        </Wrap>
                    </Box>
                </Flex>
            </Flex>
        </Flex>
    );
});
```

## 動作確認

プロジェクトをデバッグ実行します。  
Vite の開発用サーバーと React/TypeScript Webアプリケーションが起動します。

![img02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/454efe73-0bd0-8be0-db03-ffd7effba57e.png)

ホロサービスから取得した配信情報がサイドバーに表示され、選択した配信情報の複数動画表示とピックアップ表示ができました。

![img03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/71d5d6c8-3a37-6895-a9c6-29b0aaf59e5d.png)

別ウィンドウ表示とすることで、スクリーンショットの Chrome 拡張機能も利用可能です。

## おわりに

動画サイズや表示領域の制御については、大まかな方向性から具体的な実装に近い要件を Github Copilot に伝えてコードを提案してもらうことで、開発にかかる時間を大幅に削減できました。アイデアを効率よく具現化する手段のひとつとして生成AIは無くてはならないものになってきています。

このアプリもまだまだ機能拡張を進めていきたいですが、次は何をしようか考え中です。