🦆

ビデオ会議が始まったらSpotifyの音量を自動で下げてくれるChrome Extensionを作った

2022/03/19に公開

この度DucktifyというChrome拡張を開発・公開したため、作成にあたっての備忘録を残します

https://chrome.google.com/webstore/detail/ducktify/alcjppdloilmdljhfliindklnedffklm

なぜ作ったか

普段フルリモートで働いており、またSpotifyで音楽を聴きながら仕事しているのですが、
GatherやGoogle meets等でビデオ会議が始まってもイヤホンの中で爆音で音楽が鳴ってるから聞こえねえよ都度ボリュームを下げる必要があり、煩わしさを感じていました🤔

そんな中、SpotifyからWeb APIが公開されていることを知り、ドキュメントを眺めた感じボリューム設定も難なくできそうだったため、Chromeの発音状況に対して自動で音量を増減させるChrome拡張 を作ろうと思い立ちました

作ったもの

作成したもののデモ動画です
正規表現にマッチしたURLのタブで音声が発生した時に、Spotifyのアクティブデバイスの音量をあらかじめ指定した音量に自動で変更します
また、発音が終わった後はSustainで指定した秒数待った後に音量を元に戻します🎵

https://www.youtube.com/watch?v=FJ4iHPaaqsI

ダウンロードはこちらからどうぞ

https://chrome.google.com/webstore/detail/ducktify/alcjppdloilmdljhfliindklnedffklm

全体のアーキテクチャとしては以下のようになっています
(実際はこれ以外にもCloudWatchとかちょっとした監視の仕組みがありますが割愛しています)

関連するコード全体をmonorepo(非公開)で管理しており、それぞれ以下の感じで実装しています

  • Chrome拡張
    • Manifest V3対応済
    • React + Chakra UI + TypeScriptで実装
    • WebpackでChrome拡張の公開パッケージを作る
  • バックエンド
    • Spotifyへのアクセスとユーザのアクセストークンをセッション管理
    • TypeScriptで実装し、APIのインターフェースとなる型をChrome拡張から参照
    • Serverless FrameworkにてLambdaプロキシ統合のAPI Gatewayとして公開
  • インフラ
    • AWSで構築 & Terraformで管理

以下、細かなところを見ていきます🦑

所感

React + Chakra UIおもろい

Chrome拡張のポップアップページ(アイコンクリックすると表示されるやつ)のデザインは、ReactとChakra UIを使って起こしました
また、Chrome拡張のパッケージ作成にあたってはWebpackでビルドとマニフェストファイルを含めたパッケージングをおこなうようにしました

近いタイミングで同僚のkawamataさんChikamichiというすげーChrome拡張を作っていて、そちらはVue.js + Windi.cssを使われていていたので最初はそれに倣おうかとも思ったのですが、
実務はVue.js & フロントなにもわからん人間なので、いい機会だし普段とは違うものを使おう & 1から調べて作ろうと思い選定しました

https://zenn.dev/ryo_kawamata/articles/chikamichi-chrome-ex

使ってみての感想としては、そこそこ本家のUIに似せることができたので満足しています
他のフレームワーク等との比較はできませんが、初心者にもオススメできるのでないかと思いました

https://chakra-ui.com/

環境の作成にあたってはこちらの記事を参考にしました🐰

https://zenn.dev/knjname/articles/20210105tryoutchakraui

https://qiita.com/akifumii/items/07ef1d28efa1cafcf2f1

Manifest V3なにもわからん

今回拡張機能を開発するにあたっては、Manifest V3というものに準拠させています

https://developer.chrome.com/docs/extensions/mv3/intro/

ネットを漁るとChrome拡張に関する情報はManifest V2というバージョンのものがかなり多いのですが、
自分が開発を始めた2022年1月の段階で、Manifest V2の新規公開は不可という状況だったので必然的にそうなっただけだったのですが、
移行でなく新規作成でも色々とハマりどころがあったので書き遺したいと思います✍

https://developer.chrome.com/docs/extensions/mv3/mv2-sunset/

大変そうなことは逆にあっさりできた

必要な要件の洗い出しをしている時に、実装で詰まりそうに懸念していたこととして OAuthフローをChrome拡張でどう実現するのかタブの音量状態が変わったことをどう検知するかセッション管理できるのか という問題があったのですが、
結果的にはこれらはほとんど手間なく実装できたので、この点はとてもありがたかったです🐬

認証については、Chromeからchrome.identityというAPIが提供されており、
特に以下の launchWebAuthFlow を使うと、Chrome拡張上からポップアップを起動して、かつ https://xxxxxx.chromiumapp.org という形式のURLにリダイレクトさせるようにプロバイダ側で設定しておくとコールバックURLを変数で受け取るところまでコード書かずに実装できます
今回は、認証後に音量操作をする関係でトークンを保持したかったのでバックエンドを作りましたが、単にユーザーの認証のみでよければ相当楽ができそうです
また、一度認証を取ったら後であればURLのキャッシュもしてくれるようでした(詳細は今回関係なかったので調べてないです)

https://developer.chrome.com/docs/extensions/reference/identity/#method-launchWebAuthFlow

また、音量発生の検知は、当初はWeb Audio APIなどを使わなければいけないかと覚悟していたのですが、
タブ状態が変更された時に渡されてくるchrome.tabs.Tabというオブジェクトに audible というプロパティーがあり、これが true の時は発音したことがわかる…というスグレモノでなんとかなってしまいました

https://developer.chrome.com/docs/extensions/reference/tabs/#type-Tab

最後に、セッション管理というかCookieを扱うことができるかについては、マニフェストファイルにhost_permissionsというプロパティがあり、
ここにURLを指定していれば問題なく(レスポンスヘッダに Access-Control-Allow-Origin を指定する必要すらなく!)疎通ができてしまいました

https://developer.chrome.com/docs/extensions/mv3/declare_permissions/

今回はChrome拡張のスクリプト上からCookieを操作する要件がなく、permissionsにも cookies を含めなかったのでスクリプトから読めなさそうなことだけ確認してそれ以上は深堀しなかったのですが、また興が乗った時に調べたいと思います

https://developer.chrome.com/docs/extensions/reference/cookies/

状態はStorageに細かく保存 & イベント開始時に復帰させる

V2のころは、バックグラウンドに立ち上がりっぱなしのスクリプトを作ることができたようですが、V3ではService Workerという仕組みを使う必要があり、考慮が必要になってきます

https://developer.chrome.com/docs/extensions/mv3/migrating_to_service_workers/

上記マイグレーションガイドにも書いてあり、実際作ってみると実感できるのですが、
こいつはかなり虚弱な実行環境で処理実行後暇な時間が30秒も続くと率先して自らその命を終えます⚰️
↓のように、管理画面で (無効) と書いてあったらそれが目印なのですが、当初それに気づかずいつの間にか変数の値がクリアされていると思いだいぶ苦しみました…

こいつは、ポップアップページの起動やタイマー処理、ポップアップとバックグラウンドスクリプト間のメッセージパッシングといったスクリプトを揺り起こす何かが再度あらわれるとまっさらな状態で復活するので、
複数の操作やイベントをまたいで状態管理をおこないたい変数などがある場合は、chrome.storageというAPIを使って書き換えの度に状態同期 & 各イベントハンドラの先頭で状態復元をするのがセオリーのようです…

https://developer.chrome.com/docs/extensions/reference/storage/

なお、このストレージにはJSONのようなシリアライズできる値しか保持できないようなので、
Webアプリのセッションのように、起動の度に読み込み & 終了前に保存という使い方をする必要がありそうです

双方向通信がむずい

今回作ったものに特徴的な仕様として、イベントの発生源と送信方向のパターンが色々とある…というのがあります
例として、以下のようなことです

  • ポップアップ画面でボリューム設定を変更したら設定をService Workerに同期する
  • Chromeのタブの発音状態が変わった時に、音量変更をAPIリクエストし状態をポップアップ画面に通知する
  • 無音になってから一定時間経過後に音量状態を戻す(Sustain)のために一定時間後に処理を再開する

使うAPIとしては、送信側でchrome.runtime.sendMessage、受信側でchrome.runtime.onMessage.addListenerを使うだけなのでその点はシンプルに実装できてよかったです

また、音量を変化させるという仕様の性質上、同時に複数イベントが発生してしまって音量がおかしくなるリスクがあったので、
(音量を上げている途中にタブから発音が検知され音量を下げるイベントをそのまま実行するような状況を想像してください)
これに対処するために、Service Worker上に簡易的なイベントキュー(と言ってもただのオブジェクトの配列ですが…)を作り、
何かしらのリクエスト実行中にはこれにpushして並列に処理がおこなわれないように留意しました
こういうことがちょっとやりたくなった時に、シングルスレッドで動いているおかげで排他制御等気にしなくてよいのはありがたいなと思いました(あってますよね…?🤮)

ポップアップが閉じられたことの検知の仕方が分からない

今回Chrome拡張サイドで一番ハマったのが掲題の件でした👹

問題としては、今回音量増減のステータス変更があった時にバックグラウンドスクリプトからポップアップ側にメッセージパッシングをおこなう必要があったことは前述のとおりですが、
この時、ポップアップ画面の表示状態(内部的にはプロセスとして起動しているかどうか?)で挙動が変わり、表示されていない時にメッセージを受け取るとエラーとしてコンソール出力される…という不具合に対処するためでした
エラーになっても害はなくコンソールが汚れるだけだったので無視してもよかったのですが、割と頻繁に発生する状況ではあるため解決しておきたかったのはあります

すぐに思いついたこととして、ポップアップ画面が非表示になった時に対してイベントリスナ的なものが登録できれば簡単かと思ったのですが、
調べても一向に要領を得ず(V2での解決方法は色々出てきたが割と大掛かりなものが多かった)、またドキュメントを探してもよくわからなかったので、
最終的にポップアップ起動中に一定時間おき(例えば500ms)にバックグラウンド側にヘルスチェックのメッセージを送るようにし、バックグラウンド側で通知をおこなう際にそのメッセージの最終受信時刻が一定時間(たとえば550m)以上経っていたらポップアップが閉じられたものとして扱う…というコードを書いてお茶を濁しました🍵
開発終盤の些末な問題だったため悪い方法で解いてしまいましたが、正しいやり方をご存知の方がいたら教えて頂きたいです🙏

参考

Chrome拡張部分の開発にあたっては、特にこちらの記事に助けられました。.。ありがとうございます🐜

https://zenn.dev/katoaki/articles/4e7548b533d7b3

https://zenn.dev/junkawa/articles/chrome-extension-mv3-siteblocker

https://qiita.com/tkt989/items/8c0e316dcf8345efd0fb

バックエンドでやってること

長くなってきたのでバックエンド側はサッといきます🚙
特に複雑なことはしておらず、ログインしたユーザーのトークンをセッション(DynamoDBにて保存)にて管理するのと、Spotify Web APIへのリクエストをおこなっています
Spotifyより発行されるClient Secretをエンドユーザーから隠す必要があったためこのような構成としました

実装にあたっては、前述の通りServerless FrameworkによってAPI Gateway、Lambda、DynamoDBを作成し、Route53をTerraformで作る分担としました
serverless-esbuildというプラグインを使ってTSのビルドをおこなっています

https://www.serverless.com/plugins/serverless-esbuild

実務であればDynamoDBはTerraformに任せるのですが、今回は手っ取り早くローカル環境も一緒に作りたかったので、
serverless-offlineserverless-dynamodb-localで楽をするためにslsでResourcesを書いて実現しました

https://github.com/dherault/serverless-offline

https://www.serverless.com/plugins/serverless-dynamodb-local

なお、設定ファイルの作成は@serverless/typescriptを使いました
yamlで書くのと比べて型が効いて使いよかったです
また、Lambda実行ロールにDynamoDBのputItemとgetItemの権限のみつけたりレコードにTTLを設定すると言ったこともsls経由ですんなりとできるので、ちょっとしたバックエンドを作る時には本当にslsは便利だなと思いました🍮

https://github.com/serverless/typescript

Spotify Web APIについて

とにかくドキュメンテーションが充実しており、また整備されてるなという印象でした

APIはGet Playback StateSet Playback Volumeといったシンプルなものしか使わなかったのでそこまで詰まらなかったというのもありますが、
以下のOAuthのドキュメントなど、前提知識やAPI外で考慮すべき事象など程よい粒度でまとまっておりわかりやすいなと思いました👺

https://developer.spotify.com/documentation/general/guides/authorization/code-flow/

開発者用のダッシュボードも以下のようにイカしてます

なお、実際に利用開始するにあたってQuota Extension Requestというものを申請しましたが、
その申請フローも確認すべきドキュメントや提出すべき内容、観点がちゃんと説明されており書きやすかったです
自分の場合は2日程度で承認されました。承認後に追加の権限を要求する場合はそれに対して追加の承認を取る…という形式のようでした

おわりに

他の方に需要があるものかわかりませんがよければご利用ください!

https://chrome.google.com/webstore/detail/ducktify/alcjppdloilmdljhfliindklnedffklm

あと弊社に来ると仕事でChrome拡張の開発もできますので興味のある方はぜひどうぞ

https://help.lapras.com/ja/use-extension1

https://herp.careers/v1/laprasinc/pyCjKweB3I7s

Discussion