NodeCGで配信画面を作ってみよう（Reactを使おう編）

## はじめに

この記事はただカッコいいという理由だけでNodeCGを使った配信レイアウトを作ってみた備忘録の第2弾です。

今回は前回作成した現在時刻をオーバーレイ表示する機能、テロップを表示する機能をReactを使って実装していきます。

前回の記事は[こちら](https://qiita.com/nanana08/items/73e3dcb33676c93eddac)から。


この記事はTDCソフト株式会社Advent Calendarの15日目です。

https://qiita.com/advent-calendar/2023/tdc


## プロジェクトのセットアップ

さて、早速Bundleプロジェクトの設定をしていきましょう。
まずは以下のようなBundleディレクトリを作成します。（Bundle名はtemp-bundleとします）
```
temp-bundle
┣ src
┃　┣ dashboard
┃　┣ extension
┃　┗ graphics
┣ tsconfig.json
┗ package.json
```

### TypeScriptの設定

せっかくなのでTypeScriptを使ってBundleを作成してみましょう。

まずは必要なパッケージをインストールします。

```bash
npm install -D ts-nodecg
```

[ts-nodecg](https://github.com/hoishin/ts-nodecg)はNodeCGのメンテナーであるHoshin氏の作成したパッケージで、Bundle特有のReplicantやMessageの型定義を盛り込んだNodeCGの型定義を利用することができます。

#### tsconfig.json
```tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}
```

#### message.d.ts

Bundleで用いるMessageのプロパティとやり取りするデータ型の定義を行います。

```src/message.d.ts
export type MessageMap = {
	"updateTelop": {
		data: string
	}
}
```

#### replicant.ts

Bundleで用いるReplicantの型定義、および初期値を設定します。
```src/replicant.ts
export type ReplicantMap = {
    "telop": string
}

// Replicantの初期値を定義
export const replicantDefaultValues: ReplicantMap = {
    "telop": "sample-telop"
}
```

#### nodecg.d.ts

Bundleで用いるReplicant,Messageの型定義をNodeCGの型定義に追加します。

```src/nodecg.d.ts
import type { CreateNodecgInstance } from 'ts-nodecg/browser';
import type { ReplicantMap } from './replicant';
import { MessageMap } from './message';

export type NodecgInstance = CreateNodecgInstance<
    "temp-bundle",
    undefined,
    ReplicantMap,
    MessageMap
>;
```

#### global.d.ts

ブラウザ側で使用するグローバル変数、nodecgを宣言します。

```src/global.d.ts
import type { NodecgInstance } from "./nodecg";

declare global {
  const nodecg: NodecgInstance;
}
```

## Reactの設定

さて、次は今回の記事のメインテーマであるReactの設定です。

まずは必要なパッケージをインストールします。
今回はお手軽にそれっぽいダッシュボードのレイアウトを作りたいのでMUIを追加しています。

```bash
npm install react react-dom @emotion/react @emotion/styled @mdi/react @mui/material
npm install -D @types/react @types/react-dom
```

### Replicant用カスタムフック作成

ReactでReplicantを扱うためのカスタムフックを作成します。
replicant.tsで作成したReplicantの型定義を用いたTypeScript対応版です。
（[こちらの記事](https://blog.livewing.net/nodecg-react)を大変参考にさせていただきました。）

```src/hooks.ts
import { useCallback, useEffect, useState } from 'react';
import { replicantDefaultValues, ReplicantMap } from './replicant';

export const useReplicant = <T extends keyof ReplicantMap>(
  name: T
): [ReplicantMap[T] | undefined, (newValue: ReplicantMap[T]) => void] => {
  const [rep] = useState(() =>
    nodecg.Replicant(name, {
      defaultValue: replicantDefaultValues[name]
    })
  );
  const [value, setValue] = useState(rep.value);
  useEffect(() => {
    const handleChange = (newValue: ReplicantMap[T]) => setValue(newValue);
    rep.on('change', handleChange);
    return () => {
      rep.removeListener('change', handleChange);
    };
  }, [rep]);
  return [value, useCallback(newValue => (rep.value = newValue), [rep])];
};
```

### Reactテンプレート

Reactのテンプレートとして以下のファイルを使用します。
viteのReact×TypeScriptテンプレートから諸々を抜いて単純化した内容です。

```
react-template
┣ index.html
┣ index.css
┣ main.tsx
┗ App.tsx
```

#### graphics/telop

テロップ表示レイアウトの設定です。
`useReplicant`フックを用いてReplicantの値を受け取って表示しています。

```src/graphics/telop/App.tsx
import { useReplicant } from "../../hooks";

function App() {
    const [ telop ] = useReplicant<"telop">("telop");
    return (
        <div id="telop">{telop}</div>
    )
}
export default App
```

<details>
<summary>cssは前回と同じ</summary>

```src/graphics/telop/index.css
body {
    background-color: transparent;
    width: 1920px;
    height: 1080px;
    margin: 0;
    display: flex;
    align-items: flex-end;
}

#root {
    width: 100%;
}

#telop {
    width: 100%;
    font-size: 36px;
    min-height: 128px;
    display: flex;
    align-items: center;
    color: #f0f0f0;
    background-color: rgba(16, 16, 16, 0.8);
    padding: 10px;
}
```

</details>

#### dashboard/telop

テロップ操作用ダッシュボードのソースです。
前回作ったダッシュボードパネルをそのままReactで作り直しただけなので特筆して書くことはないですね。

```src/dashboard/telop/App.tsx
import { Button, Card, CardActions, CardContent, TextField } from "@mui/material";
import { useState } from "react";

function App() {
    const [ telop, setTelop ] = useState("");
    const updateTelop = () => {
        nodecg.sendMessage("updateTelop", telop);
    }
    return (
        <Card>
            <CardContent>
                <TextField multiline value={telop} onChange={(e) => setTelop(e.target.value)} fullWidth />
            </CardContent>
            <CardActions>
                <Button variant="contained" onClick={updateTelop}>更新</Button>
            </CardActions>
        </Card>
    )
}
export default App
```

#### graphics/clock

現在時刻をオーバーレイ表示するレイアウトの設定です。
こちらも特筆して書くことはなし。

<details>

<summary>src/graphics/clock</summary>

```src/graphics/clock/App.tsx
import { useState } from "react"

function App() {
    const [ time, setTime ] = useState(new Date().toLocaleTimeString());
    setInterval(() => {
        setTime(new Date().toLocaleTimeString())
    }, 1000);
    return (
        <div id="time">{time}</div>
    )
}
export default App
```

```src/graphics/clock/.css
body {
    background-color: transparent;
}
#time {
    font-size: 120px;
    color: #ffffff;
    width: fit-content;
    text-shadow: 0 0 10px orange, 0 0 15px orange;
}
```

</details>

## extensionの設定

extensionはサーバーサイドの処理なのでReactは使わずに普通にTypeScriptで実装していきます。

```src/extension/index.ts
import { replicantDefaultValues } from '../replicant';
import { NodecgInstance } from '../nodecg';

export default (nodecg: NodecgInstance) => {

    // テロップReplicantの初期化
    const telopRep = nodecg.Replicant("telop", {
        defaultValue: replicantDefaultValues['telop']
    })

    const updateTelop = (message: string) => {
        telopRep.value = message;
    }

    nodecg.listenFor("updateTelop", updateTelop);
}
```

型定義を利用している以外は特筆すべき箇所もなく、ほぼ前回と同じです。
Replicantの宣言時に型定義の恩恵が受けられますね。

## ビルドの設定

今回はviteを使ってビルドを行います。
src配下のextension, graphics, dashboardそれぞれをビルドしてBundle配下に出力するようviteの設定を行います。

↓こんな感じで出力したい
```
temp-bundle
┣ extension
┃ ┗ index.js 
┣ dashboard
┃ ┣ assets
┃ ┗ telop
┃   ┣ index.js
┃   ┗ index.html
┗ graphics
　 ┣ assets
　 ┣ telop
　 ┃ ┣ index.js
　 ┃ ┗ index.html
　 ┗ clock
　   ┣ index.js
　   ┗ index.html
```

### viteの設定

例によってまずはパッケージのインストールからです。

```bash
npm install -D vite @vitejs/plugin-react
```

#### extension

extensionのビルド設定を行います。

```src/extension/vite.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'path'

const root = resolve(__dirname);

const outDir = resolve(__dirname, "../../extension");

// https://vitejs.dev/config/
export default defineConfig({
    plugins: [],
    root: root,
    build: {
        outDir: outDir,
        lib: {
            entry: resolve(root, "index.ts"),
            name: "extension"
        },
        rollupOptions: {
            output: {
                dir: outDir,
                entryFileNames: `index.js`
            }
        }
    }
})

```

extensionはindex.jsが関数をエクスポートする必要があるので`lib`オプションを使ってライブラリとしてエクスポートしています。

#### graphics


```src/graphics/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
import { readdirSync } from 'fs';

const root = resolve(__dirname);

const outDir = resolve(__dirname, "../../graphics");

const dirList = readdirSync(root, {
    withFileTypes: true, 
}).filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);

const input = (() => {
    const retVal: {[key: string]: string} = {}
    for (const dir of dirList) {
        retVal[dir] = resolve(root, dir, "index.html");
    }
    return retVal;
})();

// https://vitejs.dev/config/
export default defineConfig({
    plugins: [react()],
    root: root,
    base: "./",
    build: {
        outDir: outDir,
        manifest: true,
        rollupOptions: {
            input: input,
            output: {
                dir: outDir,
                entryFileNames: `[name]/index.js`
            }
        }
    }
})
```

#### dashboard

dashboardとgraphicsはほとんど同じなので閉じておきます。


<details>

<summary>src/dashboard/vite.config.ts</summary>

```src/dashboard/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
import { readdirSync } from 'fs';

const root = resolve(__dirname);

const outDir = resolve(__dirname, "../../dashboard");

const dirList = readdirSync(root, {
    withFileTypes: true, 
}).filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);

const input = (() => {
    const retVal: {[key: string]: string} = {}
    for (const dir of dirList) {
        retVal[dir] = resolve(root, dir, "index.html");
    }
    return retVal;
})();


// https://vitejs.dev/config/
export default defineConfig({
    plugins: [react()],
    root: root,
    base: "./",
    build: {
        outDir: outDir,
        manifest: true,
        rollupOptions: {
            input: input,
            output: {
                dir: outDir,
                entryFileNames: `[name]/index.js`
            }
        }
    }
})

```

</details>

dashboard、graphicsは各パネルやレイアウト毎にそれぞれ分けて出力する必要があるのでrollupOptionsの設定を弄ってgraphics・dashboard配下のフォルダ毎に出力されるようにしています。

これで後々dashboardやgraphicsを増やす際にもbuild設定を変更する必要がないようになったはずです。（全て同じフォーマットのReactを使う前提なのでReact以外を使いたくなったら要修正）

### package.jsonの修正
script, nodecgの設定を追加します。
```package.json  
{
  "name": "temp-bundle",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "build": "cd src/dashboard && vite build && cd ../graphics && vite build && cd ../extension && vite build"
  },
  "nodecg": {
    "compatibleRange": "*",
    "dashboardPanels": [
      {
        "name": "telop",
        "title": "テロップ",
        "width": 5,
        "height": 10,
        "file": "telop/index.html",
        "workspace": "workspace"
      }
    ],
    "graphics": [
      {
        "file": "clock/index.html",
        "width": "1920",
        "height": "1080"
      },
      {
        "file": "telop/index.html",
        "width": "1920",
        "height": "1080"
      }
    ]
  },
  // 以下略
}
```

## アクセスしてみよう

temp-bundleをビルドしてからNodeCGを起動してみましょう。

```bash
cd bundles/temp-bundle

npm run build

cd ../..

npm start
```

![Videotogif (2) (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125818/3bbc32b5-4283-cfd8-5daf-a2edfef91679.gif)


前回と同様の配信レイアウトをReactを使って作成できました！

## おわりに

さて、今回はReactを使おう編ということで色々やってきましたが、TypeScriptやviteでのビルドなど、だいぶ設定内容が多くてゴチャゴチャしてしまいました。

何はともあれ、無事にReactを使ったNodeCGのBundle作成ができましたね。
素のHTML、JavaScriptを書くよりは断然慣れているのでこれできっと開発もやりやすくなることでしょう。

また気が向いたらNodeCGの記事を書こうと思うので期待せずにお待ちいただけると幸いです。


## 参考
https://www.nodecg.dev/

https://ja.vitejs.dev/

https://zenn.dev/cma2819/articles/intro-ncg-timekeeper

https://blog.livewing.net/nodecg-react