実際の挙手と Google Meet 上の挙手ボタンが連動する Chrome 拡張を作ったのでコード全公開&解説
最近公開した Chrome 拡張の紹介と技術的なポイントの解説です。
✨ 何を作った?
実際の挙手と Google Meet 上の挙手ボタンが連動する Chrome 拡張を作りました。
大人数のミーティングで挙手ボタンを使う機会があるのですが、ボタンを押すという動作が億劫 & 挙手ボタンの下げ忘れもたまにあり、改善したいと思ったことが開発のきっかけです。あとは、Machine Learning 系のライブラリを使って何か開発してみたいという思いもモチベーションに繋がりました。
実装コードは全てこちらのリポジトリで公開しています。
🛠 技術的なポイント
Chrome Extensionsの開発環境構築
いざ Chrome Extensions を開発しようと思っても、頻繁に開発するわけではないので環境構築に迷いますよね。今回は vitesse-webextという Chrome Extensions 用の template リポジトリを利用して環境構築を省力化しました。
vitesse-webext では TypeScript、ESLint、Vite、pnpm などモダンな開発環境が最初から全て揃えられているので、面倒な開発環境構築をスキップして即主要な機能開発に取りかかれます。最高の DX でした。
ただ、一部 content_script のビルドだけは、最初から入っていた tsup では後述する handtrackjs のバンドルがうまくいかなかったので、esbuild を追加してビルドスクリプトを独自に書いています。
挙手動作の判定処理
機能の根幹であるカメラからの挙手動作の判定処理は、handtrackjsを使っています。
handtrackjs は動画、画像から手の状態や顔を判定するライブラリです。
手の状態は以下 7 つの種類を判定できます。
今回は挙手なので、open
の状態を判定して、その状態の時は画面上の挙手ボタンを押すという仕様になっています。Machine Learning 系の知識は全くないのですが、handtrackjs が使いやすいインタフェースを公開してくれているので迷わず実装できました。
sync-raise-hand/blob/main/src/contentScripts/src/handtrack.ts
// ...
const toggleRiseHandButton = (predictions: Prediction[]) => {
const raiseHandButton = getRaiseHandButton()
raiseHandButton?.closest('span')?.classList.add(RAISE_BUTTON_CLASS)
if (predictions.some((p: Prediction) => p.label === 'open')) {
if (!isRaiseHand) {
isRaiseHand = true
raiseHandButton?.closest('span')?.classList.add(RAISE_BUTTON_RAISED_CLASS)
raiseHandButton?.click()
}
}
else {
if (isRaiseHand) {
isRaiseHand = false
raiseHandButton?.closest('span')?.classList.remove(RAISE_BUTTON_RAISED_CLASS)
raiseHandButton?.click()
}
}
}
export const startTracking = async() => {
const status = await handTrack.startVideo(videoEl)
timerID = setInterval(async() => {
if (status) {
const predictions = await model.detect(videoEl)
toggleRiseHandButton(predictions)
}
}, 1000)
}
// ...
handTrack.startVideo
でカメラと接続し、model.detect
で画像を解析、解析結果のラベルを判定して挙手ボタンの DOM を操作しています。画像の解析はsetInterval
で 1 秒ごとに行っています。
コントローラーボタンの表示
挙手判定の ON/OFF を行うコントローラーボタンは、Vue.js や React などのライブラリを使わず素の JavaScript で DOM の構築やイベントのハンドリングを行っています。
insertAdjacentHTML
で DOM を追加し、addEventListener
でチェックのイベントを購読して処理をするという構成です。
insertAdjacentHTML
は今回始めて使ってみたのですが、宣言的に DOM を書けるのでとても使いやすいですね。
src/contentScripts/src/createController.ts
// ...
const createElements = () => {
document.body.insertAdjacentHTML('beforeend', `
<div id=${CONTROLLER_ELEMENT_ID} draggable="true">
<div>
${i18next.t('controllerMessage')} ✋
</div>
<div id=${SWITCH_ELEMENT_WRAPPER_ID}>
<label id=${SWITCH_ELEMENT_ID}>
<input type="checkbox" id=${INPUT_ELEMENT_ID}>
<span></span>
</label>
</div>
</div>
`)
}
const setCheckedEvent = () => {
const controllerInput = document.querySelector<HTMLInputElement>(`#${INPUT_ELEMENT_ID}`)
if (controllerInput) {
controllerInput.addEventListener('change', async(event) => {
if ((event as any).target.checked)
await startTracking()
else
await stopTracking()
})
}
}
// ...
挙手ボタン取得時のDOM検索方法
挙手を判定したら、挙手ボタンを押すのですが、そのボタンの DOM 取得に少し工夫必要でした。
というのも、Google の Web アプリの Class 名はランダムに生成されている感があって、単純な Class 名のセレクタだとすぐ動かなくなる可能性があったからです。
そのため Class 名での指定は避けて、挙手ボタンのアイコンの SVG Path を指定するという少し変わった指定方法にしました。DOM 構造や、Class 名よりアイコンの形のほうが変わりにくいだろうという読みです。
src/contentScripts/src/constants.ts
// hand icon svg path
export const RAISE_HAND_ICON_SELECTOR = '[d="M21,7c0-1.38-1.12-2.5-2.5-2.5c-0.17,0-0.34,0.02-0.5,0.05V4c0-1.38-1.12-2.5-2.5-2.5c-0.23,0-0.46,0.03-0.67,0.09 C14.46,0.66,13.56,0,12.5,0c-1.23,0-2.25,0.89-2.46,2.06C9.87,2.02,9.69,2,9.5,2C8.12,2,7,3.12,7,4.5v5.89 c-0.34-0.31-0.76-0.54-1.22-0.66L5.01,9.52c-0.83-0.23-1.7,0.09-2.19,0.83c-0.38,0.57-0.4,1.31-0.15,1.95l2.56,6.43 C6.49,21.91,9.57,24,13,24h0c4.42,0,8-3.58,8-8V7z M19,16c0,3.31-2.69,6-6,6h0c-2.61,0-4.95-1.59-5.91-4.01l-2.6-6.54l0.53,0.14 c0.46,0.12,0.83,0.46,1,0.9L7,15h2V4.5C9,4.22,9.22,4,9.5,4S10,4.22,10,4.5V12h2V2.5C12,2.22,12.22,2,12.5,2S13,2.22,13,2.5V12h2V4 c0-0.28,0.22-0.5,0.5-0.5S16,3.72,16,4v8h2V7c0-0.28,0.22-0.5,0.5-0.5S19,6.72,19,7L19,16z"]'
src/contentScripts/src/utils.ts
export const getRaiseHandButton = () => document
.querySelector<HTMLElement>(RAISE_HAND_ICON_SELECTOR)?
.closest('button')
🎨 UIの工夫
マテリアルデザイン、ドラッグ&ドロップでの移動
Google Meet がマテリアルデザインなので、そこに違和感なく溶け込むようにコントローラーのデザインもマテリアルデザインとしました。また、コントローラーを画面内に配置する都合上、配置も自由に変えられたほうが邪魔にならず良いかなと思いドラッグ&ドロップでの移動を可能にしています。
挙手ボタンの拡大アニメーション
標準のボタンの色反転だけだと、挙手状態がわかりにくい気がしたので、挙手ボタンの拡大アニメーションを追加しました。
手を上げている感がより出て個人的には気に入っています。これは挙手ボタンへの JS でのクラスの付け替えによる CSS アニメーションで実現しています。
🎬 その他
デモ動画作成
Chrome Store の掲載用としてデモ動画の作成にも力を入れました。kapを使い画面収録して、Mac 標準の iMovie で編集しています。
コントローラーボタンにズームしたりする演出は Ken Burns エフェクトと、再生速度の微調整で実現しています。割と良い感じにできた気がしていて、Twitter での拡散につながったかなと思っています。
Product Huntへの登録
せっかくリリースしたので、海外のユーザーにも使ってもらいたいと思い Product Hunt にも登録してみました。初めての登録だったのですが、案内に従って入力するだけで、とても簡単に登録出来ました。
登録した時間が、時差を考えておらず今イチだったのですが、結果 70voted を超え、いくつか海外のユーザーからもコメントをもらえたので良かったです。
おわりに
以上、Sync Raise hand の紹介でした。
公開後、思った以上に Twitter で拡散され、各国の方から良い反応をもらえてとても嬉しかったです。個人開発はユーザーの声が何よりの励みになりますね。
より多くの人に使ってもらえるように今後も改善を進めていきたいです。
Discussion
Google Meet の挙手は Ctrl + Command + H でもできるので、KeyboardEvent で挙手をしてみました
key: 'KeyH'
では動作せずkeyCode: 72
では動作しましたなるほど〜!
そちらの方がDOMの変化にも対応できそうで良いですね!!