Vue3でYouTubeの動画を操作するChrome拡張機能を開発する

# 作ったもの

『[YouTube Chapter Editor](https://chrome.google.com/webstore/detail/youtube-chapter-editor/bjcbfemjpjpoiefolemocadinijjdmgd "YouTube Chapter Editor - Chromeウェブストア")』というYouTubeのチャプター（目次）が効率良く作成できるChrome拡張機能を作りました。

動画編集ページのプレイヤーに専用ツールが埋め込まれ、チャプター情報を入力していくことで貼り付け用のテキストが自動生成されるツールです。

![picture_pc_5b5bfe4d8082827f9d5101646e7e74d3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34972/8b440d1f-3176-1b22-4ecb-5eba11667860.gif)

YouTubeでチャプター(目次)付きの動画を投稿したことがある方はご存知かと思いますが、このチャプター情報の入力ってすごく面倒ですよね。

詳しい機能や使い方についてはこちらのnoteで書いていますのでよろしければご覧ください。
『[YouTubeのチャプター(目次)を簡単に入力できるChrome拡張機能を作りました](https://note.com/tazawa_naoto/n/n445d5665cbb1 "YouTubeのチャプター(目次)を簡単に入力できるChrome拡張機能を作りました｜田沢 直人｜note")』

# 技術について

以下のような技術を利用して開発しました。

- Vue 3 / Vuex
- Webpack
- Stylus
- ESlint / Prettier
- Font Awesome

本記事ではVue.jsのChrome拡張開発での利用、拡張機能からYouTubeの動画の操作、その他Chrome拡張機能開発のTipsについて紹介します。

# 実装

上記について実装の一部を抜粋しながら紹介します。
わかりやすくするために説明に直接関係ない処理を省略したり、関数名や変数名などはシンプルなものにしています。

## ページ内で埋め込む要素に対してVue.jsを利用する

ツールを埋め込むためにまず空divを挿入して、video要素をpropsで渡して`createApp()`します。
後ほど説明しますが、すでに埋め込まれている動画の操作をするのでYouTube Player APIではなく、video要素を直接操作することになります。

まず、ルートに設置するmain.jsです。

```js:main.js
import { createApp } from 'vue'
import App from './App.vue'
import store from './store'

// 動画プレイヤー領域の要素を取得
const playerContainer = document.getElementsByTagName('ytcp-html5-video-player')[0]

// ツールを埋め込むための空要素を挿入
playerContainer.insertAdjacentHTML('afterend', `<div id="app"><App :video="video" /></div>`)

// 動画要素を取得
const video = document.getElementsByTagName('video')[0]

const app = createApp({
  el: '#app',
  data() {
    return { video }
  },
})
app.use(store)
app.component('App', App)
app.mount('#app')
```
ちなみに`App.vue`はこんな感じ。

```vuejs:App.vue
<template>
  <div>
    <Controller :video="video" />
    <div class="chapter-contents">
      <ChapterList :video="video" />
      <Preview />
      <Errors />
    </div>
    <Logo />
  </div>
</template>

<script>
import { defineComponent } from 'vue'
import Controller from './components/Controller'
import ChapterList from './components/ChapterList'
import Preview from './components/Preview'
import Errors from './components/Errors'
import Logo from './components/TheLogo'

export default defineComponent({
  components: {
    Controller,
    Preview,
    ChapterList,
    Errors,
    Logo,
  },
  props: {
    video: {
      type: HTMLVideoElement,
      required: true,
    },
  },
})
</script>
```

## Vue.jsから動画要素を操作する

動画の操作部分の抜粋です。
video要素が持つイベント(`playing`, `pause`, `ended`, `timeupdate`)を取得して、Vueの`state`を更新します。

```vuejs:./components/Controller.vue
<template>
 <!-- 省略 -->
</template>

<script>
import { defineComponent, computed, reactive } from 'vue'
import { useStore } from 'vuex'

const SEEK_TYPE_BACK = 1
const SEEK_TYPE_FORWARD = 2

export default defineComponent({
  props: {
    video: {
      type: HTMLVideoElement,
      required: true,
    },
  },

  setup(props) {
    const store = useStore()

    const state = reactive({
      isPlaying: false,
      currentTimeSeconds: 0,
      video: props.video,
    })

    /**
     * プレイヤーイベントの登録
     */
    state.video.addEventListener('playing', () => (state.isPlaying = true))
    state.video.addEventListener('pause', () => (state.isPlaying = false))
    state.video.addEventListener('ended', () => (state.isPlaying = false))

    // 経過時間の監視
    state.video.addEventListener('timeupdate', () => {
      const currentTimeSeconds = Math.floor(state.video.currentTime)
      // 秒数が更新されたらstateの現在時間を更新
      if (state.currentTimeSeconds !== currentTimeSeconds) {
        state.currentTimeSeconds = currentTimeSeconds
      }
    }, false)

    // templateから実行する関数
    const play = () => state.video.play()
    const pause = () => state.video.pause()
    
    /**
     * templateから実行するseekの関数
     * @param {Number} seconds
     * @param {Number} type
     */
    const seek = (seconds, type) => {
      switch (type) {
        case SEEK_TYPE_BACK:
          state.video.currentTime = state.video.currentTime - seconds
          break
        case SEEK_TYPE_FORWARD:
          state.video.currentTime = state.video.currentTime + seconds
          break
      }
    }

    return {
      state,
      play,
      pause,
      seek,
    }
  },
})
</script>
```

## 状態管理について

各コンポーネント間をまたいでチャプター情報やエラー情報を扱っているので、Vuexを利用しています。
チャプターの追加/変更/削除、エラーの追加/初期化のactionを持っている感じです。

## Chrome拡張機能を多言語対応する

[chrome.i18n](https://developer.chrome.com/extensions/i18n)を利用します。
公開ディレクトリの直下に`_locales/`を作成し、言語ごとに`messages.json`を用意します。
![スクリーンショット 2020-11-22 23.14.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34972/59e2c138-afbd-f4ad-20f7-d490d0234e8b.png)

```json:_locales/ja/messages.json
{
  "ext_name": { "message": "YouTube Chapter Editor" },
  "ext_description": { "message": "YouTubeのチャプター編集用のツールです。動画の詳細ページのプレイヤーに編集ツールが埋め込まれます。" }
}
```

manifest.jsonに`default_locale`を指定します。

```json:
"default_locale": "ja"
```

manifest.jsonからは`__MSG_ext_name__`のように指定することで取得できます。

```json:manifest.json
{
  "name": "__MSG_ext_name__",
  "description": "__MSG_ext_description__",
  "default_locale": "ja"
}
```

JavaScriptからはこのように取得します。

```js
chrome.i18n.getMessage('ext_name'))
```


## JavaScriptからローカルの画像を読み込む

`extension_id`を指定する必要があります。
多言語対応で[chrome.i18n](https://developer.chrome.com/extensions/i18n)を利用している場合は、以下のように`chrome.i18n.getMessage('@@extension_id')`でIDが取得できます。

```vuejs:./components/Logo.vue
<template>
  <div class="logo">
    <span class="logo__image" :style="logoStyle"></span>
    <span class="logo__text">YouTube Chapter Editor</span>
  </div>
</template>

<script>
import { defineComponent } from 'vue'

export default defineComponent({
  setup() {
    const logoStyle = {
      backgroundImage: `url('chrome-extension://${chrome.i18n.getMessage('@@extension_id')}/icons/48.png')`,
    }
    return {
      logoStyle,
    }
  },
})
</script>
```

上記以外だと、CSS側で`chrome-extension://__MSG_@@extension_id__`と指定する方法もあります。

## 拡張機能のアイコンをクリックしてContents Script内の関数を実行する

今回の拡張機能は、対象ページに直接アクセスした場合やリロード時に自動でツールが起動するのですが、YouTubeはSPAなので他のページから遷移してきた場合はアイコンをクリックしてツールを起動する必要があります。

manifest.jsonで`background`として指定しているファイルに以下のような処理を書きます。
`content_scripts/bundle.js`はVueの実装などが入ったこの拡張機能のメインのスクリプトです。
アイコンがクリックされたら`isIconClicked`のフラグを立てます。

```js:background.js
chrome.pageAction.onClicked.addListener(tab => {
  chrome.tabs.executeScript(
    tab.id,
    {
      code: 'isIconClicked = true;',
    },
    () => {
      chrome.tabs.executeScript(null, { file: 'content_scripts/bundle.js' })
    },
  )
})
```

Contents Scriptのmain.jsで`isIconClicked`を判定して実行します。

```js:main.js
if (typeof isIconClicked !== 'undefined') {
  if (isIconClicked) {
    // アイコンがクリックされたら実行する処理
  }
}
```
他に良い方法があればコメントいただけると嬉しいです。

## 開発時のホットリロード（自動再読み込み）

[hot-reload.js](https://github.com/xpl/crx-hotreload)を利用します。
hot-reload.jsを任意の場所に設置し、manifest.jsonで以下のように設定します。

```json
"background": {
  "scripts": [ "hot-reload.js" ]
}
```

ファイルの変更を検知して、開発中にアクティブなタブが自動再読み込みされるようになります。

# 最後に

ツール自体は比較的シンプルなものなので特に実装で困ることはなかったのですが、VueでChrome拡張開発する方法や、Contents Scriptの動作についてはつまづくことも多かったです。今回は私が特に困った部分を盛り込んだ記事となりましたが、お役に立てれば幸いです。

あと、何よりこの拡張機能はYouTubeで動画を投稿されている方にはぜひ使っていただきたいです。私自身がもうこのツールなしではチャプター作成できないくらい依存しています。。
『[YouTube Chapter Editor](https://chrome.google.com/webstore/detail/youtube-chapter-editor/bjcbfemjpjpoiefolemocadinijjdmgd "YouTube Chapter Editor - Chromeウェブストア")』

普段やっているPodcastのYouTube版で[今回のツールの紹介](https://youtu.be/wXzV1zJ8MLY)をしてみました。技術の話をする番組ではないのですが、ご興味あればこちらもよろしくお願いします。（[Spotify](https://open.spotify.com/show/1ZGiyHqgpM5gTQZo9fNcok) / [Apple Podcast](https://podcasts.apple.com/jp/podcast/%E7%B5%82%E3%82%8F%E3%82%8A%E3%81%8B%E3%81%91%E3%81%AEradio/id1490455300) / [Google Podcasts](https://podcasts.google.com/feed/aHR0cHM6Ly9hbmNob3IuZm0vcy8xMTBiYTM2OC9wb2RjYXN0L3Jzcw)）