🐸

TS Kaigiの配信likeなスライド画面共有用サイトを作った

2024/09/09に公開

すごい今更な内容かもしれませんが、下書きの奥底に眠っていたので、世に出しておこうと思いました。

私はTS Kaigiに参加したのですが、その配信画面を見て、「うお〜〜かっけ〜〜!」 となったため、同様の画面に情報を集約して共有できるサイトを作りました。

TS Kaigiの配信画面について

こちらに詳しい構築方法について説明されています。

https://zenn.dev/ken7253/articles/tskaigi-streaming-layout

この配信画面は右上にワイプがあり、右下にスポンサー一覧、左上にスライド、左下に登壇者情報となっています。


上記記事より引用

モチベーション

現地でこの画面を見ていてすぐ思いました。
「うお〜これ個人で使いて〜〜」
ならばあとは作るだけなので、作りました。

ただし、TS Kaigiで用いられているNodeCGについは用いていません
NodeCGサーバーを立ち上げて、それを管理画面から情報を編集して、OBSで配信して...というのは個人で運用するには重すぎると感じたからです。
そのため以下のようなコンセプトで作ることにしました。

  • web上で情報を編集できる
  • Zoom, GoogleMeet, Slackハドルなどの画面共有機能で共有するだけのサイトにする
  • そのサイトをURLで共有したり、そのサイト上で画面共有機能は提供しない
  • 共有するスライドツールは一旦GoogleSlideのみとする

作った

https://slide-all.vercel.app/

コードはMITライセンスで公開してます。どれだけ魔改造していただいてもOKです。

https://github.com/imaimai17468/slide-all/tree/main

技術選定

  • 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について

https://www.npmjs.com/package/react-camera-pro?activeTab=readme

https://zenn.dev/shinobuy/articles/170e5fef9aa780

この記事でええやんとなって使いました。

  • 画質
    変換周りの処理の違いで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を取ってあげれば同様のことができるんじゃないかと睨んでます。

https://qiita.com/toffy/items/bb9c40bbb673e4f71abe

音声認識について

音声認識にはweb音声APIを使っています。
お手軽にリアルタイムの音声認識ができて便利です。

https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API

実装詳細は自分が前使った記事をそのまま流用してます。

https://zenn.dev/imaimai17468/articles/a3e29a59765ab6

非常に醜い3連useEffectがあります。 誰か助けてください。

字幕部分のメイン実装コード
SubtitleCotent.tsx
"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です。

https://mantine.dev/core/color-picker/

背景色設定ボタンのメインコード
SettingButton.tsx
"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も読み込めるようにするとか...

https://github.com/imaimai17468/slide-all

やっぱり、「これおもろそう!作れるんじゃね!」から始めた興味開発めっちゃ楽しいです。
皆さんにも良いコーディングライフを。

株式会社ゆめみ

Discussion