notebook

都内でWEB系エンジニアやってます。

Reactでタイマーを作ってPicture in Pictureで表示する

この記事は Reactのカレンダー | Advent Calendar 2021 - Qiita 9日目の記事です

会議やワークなどでファシリテーションする際、「ちょっと時間取るのでこの作業しましょう」や「1人○分で意見を言いましょう」といったことが良くある

Miroなどはタイマー機能があるのでそれを使えばよい

タイマー機能がないページで協働作業する場合は共有用の画面に共有したい画面とタイマーを起動したウィンドウをいい感じに配置しなければならず不便に感じていた

Picture in Pictureでタイマーが表示できればウィンドウを並べていい感じにしなくてもよいのでは?ということで調べた

調べていくうちに普通に実装されたコードが見つかったりしたがせっかくなので後学のため自分でも実装した

流れ

  • canvasをvideo要素に変換してPicture in Pictureを実現
  • canvasでtimerを実装

とりあえずタイマー部分の実装はおいておいて、canvasの要素をvideoにしてPicture in Pictureで表示するのを確かめる

Canvasをvideoタグで再生できるようにする

ということで理解するために素のJavaScriptで書いてみた

canvasの描画部分は適当な値が更新されるようにして書いてみた

  • sample-pinp-canvas.html
<html>
  <body>
    <div id="timer">
      <canvas id="canvas" width="300" height="100"></canvas>
    </div>
    <button id="start-timer" onClick="timerFn()">Start Timer</button>
    <button id="pip-button" onClick="createVideo()">Picture in Picture</button>
  </body>
  <script type="text/javascript">

    const canvasEl = document.getElementById("canvas");
    const canvasCtx = canvasEl.getContext('2d');

    const increment = () => {
      const now = Date.now();
      console.log(now);
      canvasCtx.clearRect(0, 0, canvasEl.width, canvasEl.height);
      canvasCtx.fillStyle = '#999999';
      canvasCtx.fillText(now, 50, 20);
    }

    function timerFn() {
      setInterval(increment, 1000);
    }

    async function createVideo() {
      const video = document.createElement('video');
      video.muted = true;
      video.srcObject = canvasEl.captureStream();
      video.play();
      video.addEventListener('loadedmetadata', () => {
        video.requestPictureInPicture();
      });
    }
  </script>
</html>
  • canvasのelementを取得、captureStream()でキャプチャしてvideoのsrcObjectに指定
  • videoを再生
  • videoの要素のrequestPictureInPicture()でPicture in Picture表示

f:id:swfz:20211209200909g:plain

1秒ごとに描画が更新されていることが確認できた

Canvasでタイマーの実装(React+TypeScript)

あとはCanvas上でそれっぽい感じの描画をさせる

最低限やりたいことを実現できそうなのが確認できたのでReactで実装していく

canvasに関してはcontextを取得してfillText,fillRectなどをつらつら書いていく

工夫ってほどでもないがタイマーで毎時描画するので描画の初期でclearRectする必要があるくらい

最終的なコードの抜粋

最終的にこんな感じのコードになった

Nextで作ったページに書いたのと体裁を整えたりした(Tailwindcss)ので今回の目的外のコードも入っている

import type { NextPage } from 'next';
import Head from 'next/head';
import { useEffect, useState, useRef, useCallback, ChangeEvent, SyntheticEvent } from 'react';
import { PlayIcon, StopIcon, DuplicateIcon } from '../../src/components/icon';

interface formValues {
  hour: number;
  min: number;
  sec: number;
}

const Timer: NextPage = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const videoRef = useRef<HTMLVideoElement>(null);

  const seconds = [...Array(60)].map((_, i) => i);
  const minutes = [...Array(60)].map((_, i) => i);
  const hours = [...Array(24)].map((_, i) => i);

  const [count, setCount] = useState(0);
  const [maxCount, setMaxCount] = useState(0);
  const [formValue, setFormValue] = useState<formValues>({ hour: 0, min: 0, sec: 0 });

  const getContext = (): CanvasRenderingContext2D | null => {
    const canvas = canvasRef.current;

    return canvas ? canvas.getContext('2d') : null;
  };

  async function createVideo() {
    const video = videoRef.current;
    const canvas = canvasRef.current;

    if (canvas && video) {
      video.srcObject = canvas.captureStream();
      video.play();
    }
  }

  const handleVideoEvent = (e: SyntheticEvent<HTMLVideoElement>) => {
    // TODO: さっと解決できなかったのでキャストしている
    const video = e.target as HTMLVideoElement;
    video.requestPictureInPicture();
  };

  const handleHourChange = (e: ChangeEvent<HTMLSelectElement>) => {
    setFormValue((prev) => {
      return { ...prev, hour: parseInt(e.target.value) };
    });
  };
  const handleMinChange = (e: ChangeEvent<HTMLSelectElement>) => {
    setFormValue((prev) => {
      return { ...prev, min: parseInt(e.target.value) };
    });
  };
  const handleSecChange = (e: ChangeEvent<HTMLSelectElement>) => {
    setFormValue((prev) => {
      return { ...prev, sec: parseInt(e.target.value) };
    });
  };

  const startTimer = () => {
    const count = formValue.hour * 60 * 60 + formValue.min * 60 + formValue.sec;
    setCount(count);
    setMaxCount(count);
  };

  const resetTimer = () => {
    setFormValue({ hour: 0, min: 0, sec: 0 });
    setCount(0);
  };

  const writeToCanvas = useCallback(
    (ctx: CanvasRenderingContext2D, count: number) => {
      const width = 300;
      const height = 100;

      const value = formatTime(count);
      const bgColor = count === 0 ? '#cc1074' : '#EFEFEF';
      const fgColor = count === 0 ? '#000000' : '#666666';
      const guageFgColor1 = '#ff1493';
      const guageFgColor2 = '#cc1074';
      const guageBgColor1 = '#222222';
      const guageBgColor2 = '#000000';

      // 枠
      ctx.clearRect(0, 0, width, height);
      ctx.fillStyle = bgColor;
      ctx.fillRect(0, 0, width, height);
      ctx.fillStyle = fgColor;
      ctx.font = '30px Arial';
      ctx.fillText(value, 90, 45);

      // guage border
      ctx.strokeStyle = '#666666';
      ctx.strokeRect(20, 70, 260, 10);

      // guage background
      const bgGradient = ctx.createLinearGradient(21, 71, 21, 79);
      bgGradient.addColorStop(0, guageBgColor2);
      bgGradient.addColorStop(0.4, guageBgColor1);
      bgGradient.addColorStop(0.6, guageBgColor1);
      bgGradient.addColorStop(1, guageBgColor2);

      ctx.fillStyle = bgGradient;
      ctx.fillRect(21, 71, 258, 8);

      // guage
      const fgGradient = ctx.createLinearGradient(21, 71, 21, 79);
      fgGradient.addColorStop(0, guageFgColor2);
      fgGradient.addColorStop(0.4, guageFgColor1);
      fgGradient.addColorStop(0.6, guageFgColor1);
      fgGradient.addColorStop(1, guageFgColor2);
      const remaining = 258 * (count / maxCount);

      ctx.fillStyle = fgGradient;
      ctx.fillRect(21, 71, remaining, 8);
    },
    [maxCount],
  );

  const formatTime = (count: number) => {
    const hour = Math.floor(count / 60 / 60);
    const min = Math.floor(count / 60) - hour * 60;
    const sec = count % 60;

    return `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
  };

  useEffect(() => {
    const ctx: CanvasRenderingContext2D | null = getContext();

    if (!ctx) {
      return;
    }
    if (count < 0) {
      return;
    }

    writeToCanvas(ctx, count);

    const interval = setInterval(() => {
      setCount((c) => c - 1);

      writeToCanvas(ctx, count);
    }, 1000);

    return () => clearInterval(interval);
  }, [count, writeToCanvas]);

  return (
    <>
      <Head>
        <title>Picture in Picture Timer</title>
      </Head>
      <div className="divide-y divide-gray-300">
        <div>
          <h1 className="text-3xl">Picture in Picture Timer</h1>
        </div>
        <div className="p-3">
          <div>
            <div>Picture in Pictureで表示できるタイマー</div>
            <div>時間、分、秒を指定してタイマーを発動する</div>
            <div>時間になると背景色が変わります</div>
          </div>
          <div className="p-2 divide-y divide-gray-300">
            <div id="timer">
              <canvas id="canvas" width="300" height="100" ref={canvasRef}></canvas>
            </div>
            <div className="flex p-1">
              <span className="flex-1">Hour: </span>
              <select className="flex-1 rounded" value={formValue.hour} onChange={handleHourChange}>
                {hours.map((hour) => {
                  return (
                    <option key={hour} value={hour}>
                      {hour.toString().padStart(2, '0')}
                    </option>
                  );
                })}
              </select>
            </div>
            <div className="flex p-1">
              <span className="flex-1">Minute: </span>
              <select className="flex-1 rounded" value={formValue.min} onChange={handleMinChange}>
                {minutes.map((min) => {
                  return (
                    <option key={min} value={min}>
                      {min.toString().padStart(2, '0')}
                    </option>
                  );
                })}
              </select>
            </div>
            <div className="flex p-1">
              <span className="flex-1">Second: </span>
              <select className="flex-1 rounded" value={formValue.sec} onChange={handleSecChange}>
                {seconds.map((sec) => {
                  return (
                    <option key={sec} value={sec}>
                      {sec.toString().padStart(2, '0')}
                    </option>
                  );
                })}
              </select>
            </div>
            <div className="flex flex-row p-1">
              <button
                className="flex items-center py-2 px-4 mx-1 font-semibold text-gray-800 bg-white hover:bg-gray-100 rounded border border-gray-400 shadow"
                onClick={startTimer}
              >
                <PlayIcon />
                Start Timer
              </button>
              <button
                className="flex items-center py-2 px-4 mx-1 font-semibold text-gray-800 bg-white hover:bg-gray-100 rounded border border-gray-400 shadow"
                onClick={resetTimer}
              >
                <StopIcon />
                Reset Timer
              </button>
            </div>
            <div className="p-1">
              <button
                className="flex items-center py-2 px-4 mx-1 font-semibold text-gray-800 bg-white hover:bg-gray-100 rounded border border-gray-400 shadow"
                onClick={createVideo}
              >
                <DuplicateIcon />
                Picture in Picture
              </button>
            </div>
            <video muted={true} onLoadedMetadata={handleVideoEvent} ref={videoRef} className="hidden"></video>
          </div>
        </div>
      </div>
    </>
  );
};

export default Timer;

f:id:swfz:20211209200920g:plain

感想

  • Picture in Pictureでタイマーを表示させることができた
  • Picture in Pictureで表示させた領域に対しては操作ができないので閲覧専用ではあるが便利
  • Canvas + Picture in Pictureができるのであれば他にも使えるケースがありそうなのでアイデア思いついたらまた何か作りたい

ついでにReact(Next)で実装して公開したので良ければ使ってあげてください

Picture in Picture Timer

tools.swfz.io

コードはこちら

swfz/tools: web tools

github.com

公開前にスマートフォンでもできるかと思って確認したが真っ黒だったのでちょっと後日修正します…

参考