この記事は 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表示
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;
感想
- Picture in Pictureでタイマーを表示させることができた
- Picture in Pictureで表示させた領域に対しては操作ができないので閲覧専用ではあるが便利
- Canvas + Picture in Pictureができるのであれば他にも使えるケースがありそうなのでアイデア思いついたらまた何か作りたい
ついでにReact(Next)で実装して公開したので良ければ使ってあげてください
コードはこちら
公開前にスマートフォンでもできるかと思って確認したが真っ黒だったのでちょっと後日修正します…