TS Kaigiの配信likeなスライド画面共有用サイトを作った
すごい今更な内容かもしれませんが、下書きの奥底に眠っていたので、世に出しておこうと思いました。
私はTS Kaigiに参加したのですが、その配信画面を見て、「うお〜〜かっけ〜〜!」 となったため、同様の画面に情報を集約して共有できるサイトを作りました。
TS Kaigiの配信画面について
こちらに詳しい構築方法について説明されています。
この配信画面は右上にワイプがあり、右下にスポンサー一覧、左上にスライド、左下に登壇者情報となっています。
上記記事より引用
モチベーション
現地でこの画面を見ていてすぐ思いました。
「うお〜これ個人で使いて〜〜」
ならばあとは作るだけなので、作りました。
ただし、TS Kaigiで用いられているNodeCGについは用いていません。
NodeCGサーバーを立ち上げて、それを管理画面から情報を編集して、OBSで配信して...というのは個人で運用するには重すぎると感じたからです。
そのため以下のようなコンセプトで作ることにしました。
- web上で情報を編集できる
- Zoom, GoogleMeet, Slackハドルなどの画面共有機能で共有するだけのサイトにする
- そのサイトをURLで共有したり、そのサイト上で画面共有機能は提供しない
- 共有するスライドツールは一旦GoogleSlideのみとする
作った
コードはMITライセンスで公開してます。どれだけ魔改造していただいてもOKです。
技術選定
- Next.js
- 個人開発で気軽に使ってるフレームワーク
- Mantine UI
- Zero-Runtime対応でコンポーネントの数も膨大なので、パパッとUI構築したいときにおすすめです
- Jotai
- シンプルに状態管理したい時に
- Biome
- コードの自動フォーマットやLintを実行するツールで、ESLintとの互換性も高い
- 言語によっては対応してないのでちょっと注意
- Zod & Mantine-Form-Zod-Resolver
- フォームバリデーション
- React Camera Pro
- ReactでPCのカメラを扱うために使いました。
- react-webcamでも良さそう(使用理由は後述)
実装追記ポイント
React Camera Proについて
この記事でええやんとなって使いました。
- 画質
変換周りの処理の違いでreact-webcamと比べてきれいに取れる- スクリーンショットのアスペクト比
親要素のスタイルで調整した<video>タグにあったスクリーンショットが取得できる
らしいです。(違っているところがあったらごめんなさい)
使い方はこんな感じ
"use client";
import { useRef } from "react";
import { Camera } from "react-camera-pro";
const cameraErrors = {
noCameraAccessible: "No camera device accessible. Please connect your camera or try a different browser.",
permissionDenied: "Permission denied. Please refresh and give camera permission.",
switchCamera: "It is not possible to switch camera to different one because there is only one video device accessible.",
canvas: "Canvas is not supported.",
};
export const CameraContent = () => {
const camera = useRef(null);
return (
<div>
<Camera ref={camera} errorMessages={cameraErrors} />
</div>
);
};
流石に背景ぼやかしとかはできないので、そういうことをしたい場合は別途実装する必要があります。
👇はreact-webcamを使っていますが、react-camera-proでもうまくrefを取ってあげれば同様のことができるんじゃないかと睨んでます。
音声認識について
音声認識にはweb音声APIを使っています。
お手軽にリアルタイムの音声認識ができて便利です。
実装詳細は自分が前使った記事をそのまま流用してます。
非常に醜い3連useEffectがあります。 誰か助けてください。
字幕部分のメイン実装コード
"use client";
import { ActionIcon, Box, Flex, Text } from "@mantine/core";
import { PauseIcon, PlayIcon, TrashIcon } from "@radix-ui/react-icons";
import { useEffect, useState } from "react";
export const SubtitleContent: React.FC = () => {
const [isRecording, setIsRecording] = useState(false);
const [text, setText] = useState<string>("");
const [transcript, setTranscript] = useState<string>("");
const [recognition, setRecognition] = useState<SpeechRecognition | null>(
null,
);
useEffect(() => {
if (typeof window !== "undefined") {
const recognition = new webkitSpeechRecognition();
recognition.lang = "ja-JP";
recognition.continuous = true;
recognition.interimResults = true;
setRecognition(recognition);
}
}, []);
useEffect(() => {
if (!recognition) return;
if (isRecording) {
recognition.start();
} else {
recognition.stop();
}
}, [isRecording]);
useEffect(() => {
if (!recognition) return;
recognition.onresult = (event) => {
const results = event.results;
for (let i = event.resultIndex; i < results.length; i++) {
if (results[i].isFinal) {
setText(results[i][0].transcript);
setTranscript("");
} else {
setTranscript(results[i][0].transcript);
}
}
};
}, [recognition]);
return (
<Flex gap={16}>
<Box flex={1}>
<Text c="gray.5">{transcript}</Text>
<Text>{text}</Text>
</Box>
<Flex gap={8}>
<ActionIcon
bg={isRecording ? "red.6" : "green.6"}
onClick={() => setIsRecording((prev) => !prev)}
aria-label={isRecording ? "Pause" : "Record"}
>
{isRecording ? <PauseIcon /> : <PlayIcon />}
</ActionIcon>
<ActionIcon
bg="red.6"
onClick={() => setText("")}
aria-label="Subtitle Delete"
>
<TrashIcon />
</ActionIcon>
</Flex>
</Flex>
);
};
};
また、質については、自分の部屋とかだと普通に認識してますが、ガヤガヤしてる外部だと聞き取られないことがままあります。
そういった場合でもspeech to textをしたい場合は外部サービスを使うしかないかも...
カラーピッカーについて
影が薄いですが、右上の歯車ボタンを押すと背景色を変えれます。
この実装は超シンプルで、MantineUIのColorPickerを使っただけです。サンキューMantineUI。
あとはstateで管理しつつInputとも同期させて、決定押したらjotaiのatomを更新すればOKです。
背景色設定ボタンのメインコード
"use client";
import { bgColorAtom } from "@/atom/bgColorAtom";
import {
ActionIcon,
Button,
ColorPicker,
Input,
Popover,
Stack,
Text,
} from "@mantine/core";
import { GearIcon } from "@radix-ui/react-icons";
import { ColorWheelIcon } from "@radix-ui/react-icons";
import { useAtom } from "jotai";
import { useState } from "react";
export const SettingButton: React.FC = () => {
const [color, setColor] = useState<string>("#1971c2");
const [_, setBgColor] = useAtom(bgColorAtom);
return (
<Popover width={300} position="bottom" shadow="md">
<Popover.Target>
<ActionIcon
bg="green.6"
pos="absolute"
top={8}
right={8}
aria-label="screen-setting"
>
<GearIcon />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={4}>
<Text fw={700}>BackgroundColor</Text>
<ColorPicker format="rgba" value={color} onChange={setColor} />
<Input value={color} onChange={(e) => setColor(e.target.value)} />
<Button
leftSection={<ColorWheelIcon />}
onClick={() => {
setBgColor(color);
}}
>
Set Color
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
終わりに
上記で述べた通り、サイトのコードはMITライセンスなので好きに改造していただいてOKです!
例えばGoogleSlideしか読み込めないようになっているところを、gammaやCanvaも読み込めるようにするとか...
やっぱり、「これおもろそう!作れるんじゃね!」から始めた興味開発めっちゃ楽しいです。
皆さんにも良いコーディングライフを。
Discussion