Nuxt.js + Netlifyで社内向けPodcastを作った話

タンバリン大阪オフィスでエンジニアアルバイトをしている平井です。

こちらはJAMstackアドベントカレンダー19日目の記事です

qiita.com

内容

  • 開発経緯
  • Podcastについて
  • 技術的な話
  • 所感

開発経緯

タンバリン大阪オフィスでは tambourine radio という名前で不定期に社内向けのラジオを収録しています。

何回か収録を重ねるにつれて社員の方から「ラジオのアーカイブを聞けるpodcastサイトを作ろう」 と提案を頂いたので、インターン生(僕を含めて3人)でチーム開発をしました。

大まかな技術選定やチケットの作成は3人で行ったのですが、コーディングの段階に入って諸事情(他の方のインターン終了など)があり、僕がほぼ個人で制作する形になりました

Podcastについて

社内限定公開なので実際に見ていただくことはできないんですが、ざっくりとしたアプリの仕様等を画面のキャプチャと合わせて紹介していきたいと思います

まずトップ画面(PC版)がこんな感じです

f:id:did0es:20191212160538p:plain

さっそく適当に再生してみましょう、クリックするとこのようなモーダルが画面下部に出現します

f:id:did0es:20191212163947p:plain

これはオーディオプレイヤーです、早送りや巻き戻し、音量の調整(ミュート込み)ができます

次に左サイドのページネーションを操作してみましょう、2019-10 をクリックすると以下のように日付で絞り込みができます

f:id:did0es:20191212164301p:plain

この操作は再生を途切れさせることなく行うことができます。soundcloundやspotify web playerを参考にオーディオプレイヤーは制作しました。Nuxtだとこういった機能実装が簡単にできます( 技術的な話 セクションにて実装内容をお話します)

この他にもYouTubeを参考にしたショートカットキーバインド対応や、スマホ対応などもしています

f:id:did0es:20191212161716p:plain:w300 f:id:did0es:20191212165720p:plain:w300

以上ざっくりとしたpodcastの紹介でした。

技術的な話

はじめに技術選定周辺のお話です。

多少vueが読めれば開発ができる や 社内に詳しい方がいる という観点からNuxt.jsを採用しています。

Nuxt.jsの中では素のHTML、CSS、JSを用いています。

TypeScriptは当時のチームの技術レベルや、今後インターン生向けのタスクにすることを考えて導入は見送りました(開発段階に移行してからは一部使用していますが、vueコンポーネント自体は素のJSで書いています)

また、最初はbootstrapを使用してスタイルをつける流れがあったのですが結構重くなりそうだったので素のCSSを書いてスタイルを作成しています。

テスティングにはJestを使用したユニットテストと一部スナップショットテストを行っています。途中からの導入という形でテストを書き始めたため、まだ実装に不完全な部分が残っていたり状態の変化が複雑でテストが書きにくいといったり課題山積みなので今後に期待という感じです...

lintにはeslintを使用しています。prettier無しのeslint rules自作なのではっきり言って結構つらい感じになってきています。。airbnbのruleとかもういっそprettier導入してしまったほうが良いので、こちらも今後に...()という感じです

前述の通りほぼ個人開発になってしまったのでdocsの作成があまり進んでいないのですが、JSDocを導入してみるのも良いかもしれないなと思ったり思わなかったりしています(現時点ではGitHubwikiに僕が手書きしています)

・・・

次にこちらがアプリの簡易なアーキテクチャ図です

f:id:did0es:20191212182944p:plain

ラジオの音源のmp3ファイルのみGoogle Driveに置いています。開発当初はGitHub上で管理していましたが、収録を重ねていくうちにGitHubでのリポジトリ容量制限に到達することを避けるために音源のみ外部で管理する方向に変更しました。

ホスティングはNetlifyで行っており、ビルド時に直接Google APIを叩き、取得したmp3音源を保存したディレクトリへのpathをJSON APIとしてラジオの情報とともに管理しています。

このJSON APIなんですが、JSで扱いやすくするためにparserをかませてJSONに変換していて、実際の設定には以下のようなyamlを用いています

- title: tambourine radio 第1回
  date: '2019-01-16'
  audioUrl: https://tambourine-podcast.netlify.com/audio/tr001.mp3
  members: hoge,foo,piyo

yamlからJSONへのパースには

www.npmjs.com

を使用して、デプロイ前にローカルで行っています(ここらへんデプロイ時に自動化できれば楽そう)

・・・

では実装の細かい話に入っていきます

ディレクトリ構成は以下の通りです(distの一部中身やnode_modulesなど実装に直接関係のない部分は除外しています)

.
├── README.md
├── app.html
├── assets
│   └── images
│       ├── tambourin_icon-04.svg
│       └── tambourne_logo-04.svg
├── components
│   ├── AppBody
│   │   └── index.vue
│   ├── AppHead
│   │   └── index.vue
│   ├── AppPagination
│   │   └── index.vue
│   ├── ArchiveList
│   │   └── index.vue
│   ├── AudioPlayer
│   │   └── index.vue
│   ├── BackgroundColor
│   │   └── index.vue
│   └── MenuIcon
│       └── index.vue
├── contents
│   ├── contents.json
│   └── radio.yml
├── coverage
├── dist
│   └── audio
├── jest.config.js
├── layouts
│   └── default.vue
├── netlify.toml
├── nuxt.config.js
├── package-lock.json
├── package.json
├── pages
│   ├── index.vue
│   ├── list
│   │   └── _date.vue
│   └── list.vue
├── process
│   ├── compress.js
│   ├── googleDrive
│   │   ├── client.ts
│   │   └── download.ts
│   └── yaml2json.js
├── static
│   ├── audio
│   ├── favicon.ico
│   ├── images
│   │   └── tambourine.png
│   └── json
│       └── radio.json
├── store
│   ├── audio.js
│   ├── filter.js
│   └── index.js
├── test
│   ├── components
│   │   ├── AppBody.spec.js.tmp
│   │   ├── ArchiveHead.spec.js.tmp
│   │   ├── BackgroundColor.spec.js
│   │   ├── MenuIcon.spec.js
│   │   └── __snapshots__
│   │       ├── BackgroundColor.spec.js.snap
│   │       └── MenuIcon.spec.js.snap
│   ├── pages
│   │   ├── __snapshots__
│   │   │   └── list.spec.js.snap
│   │   └── list.spec.js
│   └── store
│       └── filter.spec.js
├── tsconfig.json
└── yarn.lock

使用したNuxt.jsはバージョン2.4なので、その仕様に準拠したディレクトリ構成 + 一部ディレクトリを追加して処理をまとめています。

ここからは以下のグリッドで囲ったUIコンポーネント3つと状態管理、Google Driveからのダウンロード部分についてコードを交えながらお話していきます

f:id:did0es:20191219134022p:plain

UIコンポーネント

画像に振ってある順番で進めていきます

1. ページネーション

YYYY-MMでラジオのアーカイブを分けています

https://i.gyazo.com/9f9c8e77ddb069164b16901d739efeb6.gif

以下は components/AppPagination/index.vue のコードです(styleは省いています)

<template>
  <div class="menu-wrap">
    <div class="menu-content-wrap">
      <div class="menu-content">
        archives
        <nuxt-link to="/list" :style="itemsStyle" @click.native="setDate(null)">
          all
        </nuxt-link>
        <div v-for="dateData in radioDateDatas" :key="dateData" @click="closeMenu">
          <nuxt-link v-if="dateData!==null" :to="`/list/${dateData}`" :style="itemsStyle" @click.native="getDate, setDate(dateData)">
            {{ dateData }}
          </nuxt-link>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      radioData: null,
      radioDateDatas: null,
      itemsStyle: `
        backgroundColor: rgb(60, 50, 50);
        marginTop: 1px;
        borderRadius: 2px;
        textAlign: center;
        fontSize: 14px;
      `
    }
  },
  computed: {
    date() {
      return this.$store.state.filter.date
    },
    isActiveMenuIcon() {
      return this.$store.state.isActiveMenuIcon
    },
    showPagination() {
      return this.$store.state.showPagination
    }
  },
  mounted() {
    this.getRadioData()
  },
  methods: {
    async getRadioData() {
      if (process.env.FLAG === 'dev') {
        const res = await this.$axios.get('http://localhost:3000/json/radio.json')
        this.radioData = res.data
      } else {
        const res = await this.$axios.get('https://tambourine-podcast.netlify.com/json/radio.json')
        this.radioData = res.data
      }
      this.getDate()
    },

    // ページネーション表示用の日付取得し、整形
    getDate() {
      this.radioData = Object.entries(this.radioData)
      this.radioDateDatas = []
      const ptnSplit = /[-]/
      for (let i = 0; i < this.radioData.length; i++) {
        this.radioDateDatas[i] = this.radioData[i][1].date.split(ptnSplit)
        this.radioDateDatas[i] = this.radioDateDatas[i][0] + '-' + this.radioDateDatas[i][1]
        if (this.radioDateDatas[i - 1] !== null) {
          if (this.radioDateDatas[i] === this.radioDateDatas[i - 1]) {
            this.radioDateDatas[i - 1] = null
          }
        }
      }
      const tmpArray = this.radioDateDatas.slice().reverse()
      this.radioDateDatas = tmpArray
    },

    setDate(d) {
      if (d === null) this.$store.commit('filter/setDate', null)
      else this.$store.commit('filter/setDate', d)
      this.startFiltering()
    },

    // store/filter.jsのactionのフィルターをdispatch
    async startFiltering() {
      if (this.date || this.date === null) await this.$store.dispatch('filter/getContents')
    },

    // レスポンシブ対応
    closeMenu() {
      if (window.innerWidth < 768) {
        this.$store.commit('updateIsActiveMenuIcon', !this.isActiveMenuIcon)
        this.$store.commit('updateShowPagination', !this.showPagination)
      }
    }
  }
}
</script>

ざっくりとした処理の流れは、

  • getRadioData()mounted で叩かれ、content/contents.jsonJSON API を取得
  • 取得完了後 getDate() が呼び出され、日付を YYYY-MM に成形
  • <template>内で表示

という順で進んでいます。

簡単な正規表現と組み込み関数で contents/contents.json にあるAPIdataYYYY-MM-DD から YYYY-MM にしています。

また、 <template> 内での <nuxt-link> に対するstyleなんですが、<style>内で定義したものが適用できなかったため、dataの中で文字列として持っています(v-forで展開した要素にそれぞれ(擬似クラスのhover等)スタイルをうまく適用させる方法を模索中です...)

setDate()starFiltering() はアイテム表示部分の実装に関連しているので次のセクションでまとめてお話します

2. ラジオのアイテム表示部分

アイテムをクリックするとプレイヤーが開き、再生が始まります。

https://i.gyazo.com/aba8c4f02cedaab4bd6822f5366f684c.gif

ページネーション部分と連携しており、1. の components/AppPagination/index.vue 内で定義されている setDate()starFiltering() で Vuexストアの状態を更新してアイテム表示に反映させています

こちらはアイテム表示を管理している components/ArchiveList/index.vue のコードです。例によってstyleは省いています

<template>
  <div class="list">
    <div v-for="data in radioContents" :key="data.title" :style="itemStyle" @click="selected(data.title)">
      <div :style="{'marginLeft': '12px'}">
        <div :class="{defaultTitleStyle:!smallWindow, smallTitleStyle:smallWindow}">
          {{ data.title }}
        </div>
        <div :class="{defaultMetaStyle:!smallWindow, smallMetaStyle:smallWindow}" :style="metaItemStyle">
          {{ data.date }} / {{ data.members }}
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      radioDateDatas: null,
      radioDataArray: null,
      smallWindow: false,
      itemStyle: `
        backgroundColor: rgb(60, 50, 50);
        color: rgb(240, 240, 240);
        overflow: hidden;
        height: 60px;
        marginTop: 4px;
        paddingTop: 4px;
        paddingBottom: 4px;
        cursor: pointer;
        borderRadius: 2px;
        fontFamily: 'Noto Sans JP';
      `,
      metaItemStyle: `
        color: rgb(180, 180, 180);
      `,
      res: null
    }
  },
  computed: {
    latestRadioNumber() {
      return this.$store.state.audio.latestRadioNumber
    },
    playState() {
      return this.$store.state.audioPlayState
    },
    audioElement() {
      return this.$store.state.audio.audioElement
    },
    currentTime() {
      return this.$store.state.audio.currentTime
    },
    pausedTime() {
      return this.$store.state.audio.pausedTime
    },
    paginationKey() {
      return this.$store.state.filter.paginationKey
    },
    radioContents() {
      return this.$store.state.filter.contents
    }
  },
  mounted() {
    const pathFilterPtn = /\//
    const filteredPath = this.$route.path.split(pathFilterPtn)
    this.$store.commit('filter/setDate', filteredPath[2])
    this.$store.dispatch('filter/getContents')
    this.$store.commit('audio/setDurationTime', 0)
    // レスポンシブ対応
    if (window.innerWidth < 768) this.smallWindow = true
    else if (window.innerWidth >= 768) this.smallWindow = false
  },
  methods: {
    resetPlayState() {
      this.$store.commit('updateAudioPlayState', false)
    },

    resetProgress() {
      this.$store.commit('audio/resetIncrementTime', 0)
      this.$store.commit('resetProgressBar', 0)
      this.$store.commit('audio/setCurrentTime', 0)
      this.$store.commit('audio/setPausedTime', 0)
      this.$store.commit('updateShowTmpTime', false)
      this.$store.commit('audio/resetProgressBar', 100)
      this.$store.commit('audio/resetMinCurrentTime', 0)
    },

    // コレクションアイテムclickで選択時にtitleでpickし、再生
    selected(title) {
      for (let i = 0; i < this.radioContents.length; i++) {
        if (title === this.radioContents[i].title) {
          this.$store.commit('audio/setLatestRadioNumber', i)
          this.updateRadioData()
        }
      }
      if (this.playState) {
        this.audioElement.pause()
        this.$store.commit('updateAudioPlayState', false)
      }
      this.audioElement.currentTime = 0
      this.updateRadioData()
      this.$store.commit('updateShowPlayer', true)
      this.resetProgress()
      setTimeout(() => {
        this.toPlay()
      }, 500)
    },

    updateRadioData() {
      this.audioElement.pause()
      const latestDate = this.radioContents[this.latestRadioNumber]
      this.$store.commit('audio/setRadioTitle', latestDate.title)
      this.$store.commit('audio/setRadioDate', latestDate.date)
      this.$store.commit('audio/setRadioMembers', latestDate.members)
      this.$store.commit('audio/setAudioUrl', latestDate.audioUrl)
      this.resetPlayState()
    },

    toPlay() {
      this.audioElement.play()
      this.$store.commit('updateAudioPlayState', true)
      this.$store.commit('audio/setCurrentTime', 0)
      this.$store.commit('audio/setDurationTime', this.audioElement.duration)
    }
  }
}
</script>

(結構いろんな処理が混ざっていてファットな感じなので切り分けたいですね...)

またざっくり処理の流れなんですが、mountedではまず以下の処理で表示するアイテムをセットしています

const pathFilterPtn = /\//
const filteredPath = this.$route.path.split(pathFilterPtn)
this.$store.commit('filter/setDate', filteredPath[2])
this.$store.dispatch('filter/getContents')

次にプログレスバー(再生バー)の初期化を

this.$store.commit('audio/setDurationTime', 0)

で行っています。

取得した radioContents をforループで表示するだけなんですが、それだと各々のアイテムをクリックした際に再生されないのでそれを行うために selected() を作っています

// 呼び出している箇所(template)
<div v-for="data in radioContents" :key="data.title" :style="itemStyle" @click="selected(data.title)">

// コレクションアイテムclickで選択時にtitleでpickし、再生
selected(title) {
  for (let i = 0; i < this.radioContents.length; i++) {
    if (title === this.radioContents[i].title) {
      this.$store.commit('audio/setLatestRadioNumber', i)
      this.updateRadioData()
    }
  }
  if (this.playState) {
    this.audioElement.pause()
    this.$store.commit('updateAudioPlayState', false)
  }
  this.audioElement.currentTime = 0
  this.updateRadioData()
  this.$store.commit('updateShowPlayer', true)
  this.resetProgress()
  setTimeout(() => {
    this.toPlay()
  }, 500)
},

store/filter.js から取得した radioContents に現在再生されているラジオのデータが入っているので、それを selected() の引数の title と比較して store/audio.jslatestRadioNumber を更新する形で実装しています。

以下の部分で ラジオの再生とプログレスバーの状態を更新しています

if (this.playState) {
  this.audioElement.pause()
  this.$store.commit('updateAudioPlayState', false)
}
this.audioElement.currentTime = 0
this.updateRadioData()
this.$store.commit('updateShowPlayer', true)
this.resetProgress()
// 連続再生停止の回避
setTimeout(() => {
  this.toPlay()
}, 500)

3. プレイヤー

個人的にかなり力を入れて作ったのがこのプレイヤーの部分なのでちょっと詳しくお話したいと思います。

各アイコンをクリックすることで、

  • 再生/停止

f:id:did0es:20191219155536p:plain:w100

  • 30秒早送り/巻き戻し

f:id:did0es:20191219155531p:plain:w100

  • アイテムの前/後

f:id:did0es:20191219155533p:plain:w100

  • 音量の調整/ミュート

f:id:did0es:20191219161453p:plain

こういった感じで操作できます

https://i.gyazo.com/197268ddbd24a91b97c3dc25cfa7719b.gif

また、プログレスバーは自前で実装しています

https://i.gyazo.com/55680a2ef506a981716dd0c24a96dcf1.gif

Spotify Web Playerを参考に、Chromeのインスペクターでポチポチ触りながら時々圧縮されたJSを読みに行ったりして結構力技で実装しています...

以下がプレイヤー部分のコード( components/AudioPlayer/index.vue )になります。styleは省いています

多分一番ファットなvueファイルなのでこれの切り出し等リファクタを適宜やらないといけないです...😇

<template>
  <section class="container">
    <div class="contents-wrap">
      <div class="player">
        <div class="button-wrap">
          <span class="button undo" @click="toUndo">
            <font-awesome-icon :icon="undo" />
          </span>
          <span class="button back" @click="toBack">
            <font-awesome-icon :icon="back" />
          </span>
          <span v-if="!$store.state.audioPlayState" class="button play" @click="toPlay">
            <font-awesome-icon :icon="play" />
          </span>
          <span v-else-if="$store.state.audioPlayState" class="button stop" @click="toStop">
            <font-awesome-icon :icon="stop" />
          </span>
          <span class="button forward" @click="toForward">
            <font-awesome-icon :icon="forward" />
          </span>
          <span class="button redo" @click="toRedo">
            <font-awesome-icon :icon="redo" />
          </span>
        </div>
        <div class="radio-meta" :style="radioStyle" :class="{defaultMetaFontSize:!smallWindow, metaFontSize:smallWindow}">
          <div :style="radioTitleStyle">
            {{ radioTitle }}
          </div>
          <div :style="radioMetaStyle">
            {{ radioDate }} / {{ radioMembers }}
          </div>
        </div>
        <div class="progress-bar-wrap">
          <div v-if="showTmpTime" class="progress-time">
            {{ minCurrentTime }}:{{ (parseInt(pausedTime % 60) + 30).toString().padStart(2, '0') }}
          </div>
          <div v-if="!showTmpTime" class="progress-time">
            {{ minCurrentTime }}:{{ parseInt(currentTime % 60).toString().padStart(2, '0') }}
          </div>
          <div id="progress-bar" class="progress-bar-bg" @click="setProgressBar($event)">
            <div class="progress-bar-glid">
              <div class="progress-bar" :style="progress" />
            </div>
          </div>
          <div v-if="isNaN(durationTime)" class="progress-time">
            0:00
          </div>
          <div v-if="!isNaN(durationTime)" class="progress-time">
            {{ parseInt(durationTime / 60, 10) }}:{{ parseInt(durationTime % 60).toString().padStart(2, '0') }}
          </div>
        </div>
      </div>
      <div v-if="showVolSlider" class="slider-wrap">
        <div class="volume-icon-wrap">
          <font-awesome-icon v-if="showVolIcon" class="volume-icon" :icon="volumeOn" @click="updateVolMuted" />
          <font-awesome-icon v-if="!showVolIcon" class="volume-icon" :icon="volumeMute" @click="updateVolMuted" />
        </div>
        <div class="volume-slider">
          <input id="volume" v-model="volumeValue" type="range" min="0" max="100">
        </div>
      </div>
    </div>
  </section>
</template>

<script>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import {
  faPlay,
  faStop,
  faStepForward,
  faStepBackward,
  faVolumeUp,
  faVolumeMute,
  faRedo,
  faUndo
} from '@fortawesome/free-solid-svg-icons'
export default {
  components: {
    FontAwesomeIcon
  },
  data() {
    return {
      radioStyle: `
        color: #fff;
        height: 36px;
        display: flex;
        justifyContent: center;
        flexDirection: column;
      `,
      barStyle: `
        transform: scaleX(0);
      `,
      radioTitleStyle: `
        textAlign: center;
        fontSize: 16px;
        color: rgb(240, 240, 240);
      `,
      radioMetaStyle: `
        textAlign: center;
        fontSize: 12px;
        width: 400px;
        color: rgb(190, 190, 190);
      `,
      play: faPlay,
      stop: faStop,
      forward: faStepForward,
      back: faStepBackward,
      volumeOn: faVolumeUp,
      volumeMute: faVolumeMute,
      redo: faRedo,
      undo: faUndo,
      volumeValue: 50,
      showVolIcon: true,
      showVolSlider: true,
      smallWindow: false,
      start: null,
      now: null,
      intervalId: null
    }
  },
  computed: {
    latestRadioNumber() {
      return this.$store.state.audio.latestRadioNumber
    },
    radioTitle() {
      return this.$store.state.audio.radioTitle
    },
    radioDate() {
      return this.$store.state.audio.radioDate
    },
    radioMembers() {
      return this.$store.state.audio.radioMembers
    },
    playState() {
      return this.$store.state.audioPlayState
    },
    audioElement() {
      return this.$store.state.audio.audioElement
    },
    audioVolume() {
      return this.$store.state.audio.audioVolume
    },
    currentTime() {
      return this.$store.state.audio.currentTime
    },
    minCurrentTime() {
      return this.$store.state.audio.minCurrentTime
    },
    pausedTime() {
      return this.$store.state.audio.pausedTime
    },
    durationTime() {
      return this.$store.state.audio.durationTime
    },
    incrementTime() {
      return this.$store.state.audio.incrementTime
    },
    showTmpTime() {
      return this.$store.state.showTmpTime
    },
    progress() {
      return {
        transform: `translate(-${this.progressBar}%)`
      }
    },
    progressBar() {
      return this.$store.state.audio.progressBar
    },
    radioData() {
      return this.$store.state.filter.contents
    },
    playRate() {
      return this.$store.state.audio.playRate
    }
  },
  mounted() {
    this.getRadioData()
    this.audioElement.value = this.audioVolume
    this.$store.commit('audio/setDurationTime', this.audioElement.duration)
    this.updateVolume()

    this.setAudioTimeCount()
    this.handleVolSlider()
    window.addEventListener('resize', this.handleVolSlider)
    this.setKeydown()
  },
  methods: {
    // カウントアップ
    setAudioTimeCount() {
      this.intervalId = window.setInterval(() => {
        // ラジオの全時間表示をセット
        if (isNaN(this.audioElement.durationTime)) {
          this.$store.commit('audio/setDurationTime', this.audioElement.duration)
        }

        // カウントアップ実行
        if (this.currentTime < this.audioElement.duration && this.playState) {
          // this.$store.commit('audio/setIncrementTime', 1 * this.playRate)
          this.$store.commit('audio/setIncrementTime', 1)
          this.$store.commit('audio/setCurrentTime', this.incrementTime)
          this.$store.commit('audio/setProgressBar', 1 / this.durationTime * 100)
        }

        // タイマーの 分:秒 の秒部分セット
        if (this.currentTime % 60 === 0 && this.currentTime > 0) this.$store.commit('audio/setMinCurrentTime', parseInt(this.currentTime / 60))

        // forward機能
        if (this.currentTime > this.durationTime) this.toForward()
      }, 1000 * 1 / this.playRate)
    },

    // ラジオのデータ取得
    async getRadioData() {
      if (process.env.FLAG === 'dev') {
        const res = await this.$axios.get('http://localhost:3000/json/radio.json')
        this.radioData = res.data
      } else {
        const res = await this.$axios.get('https://tambourine-podcast.netlify.com/json/radio.json')
        this.radioData = res.data
      }
    },

    // ボリューム調整
    updateVolume() {
      const volume = document.getElementById('volume')
      // ボリュームバーの移動をaudioElementのボリュームに反映
      volume.addEventListener('change', () => {
        this.$store.commit('audio/setAudioVolume', Math.round(volume.value * 0.1) * 0.1)
        this.audioElement.volume = this.audioVolume
      }, false)
    },

    // ミュート機能
    updateVolMuted() {
      if (this.showVolIcon) {
        this.volumeValue = 0
        this.audioElement.muted = true
      } else {
        this.volumeValue = 50
        this.audioElement.muted = false
      }
      this.showVolIcon = !this.showVolIcon
    },

    // ラジオのデータの更新
    updateRadioData() {
      this.audioElement.pause()
      const latestDate = this.radioData[this.latestRadioNumber]
      this.$store.commit('audio/setRadioTitle', latestDate.title)
      this.$store.commit('audio/setRadioDate', latestDate.date)
      this.$store.commit('audio/setRadioMembers', latestDate.members)
      this.$store.commit('audio/setAudioUrl', latestDate.audioUrl)
      this.$store.commit('audio/setDurationTime', this.audioElement.duration)
      this.resetPlayState()
    },

    // プログレスバーの初期化
    setProgressBar(e) {
      const maxWidth = document.getElementById('progress-bar').clientWidth
      this.$store.commit('audio/resetProgressBar', 100 - (parseInt(e.clientX - e.currentTarget.getBoundingClientRect().left) / maxWidth) * 100)
      this.doSeek(e)
    },

    // プログレスバーの更新
    updateProgress() {
      this.$store.commit('audio/setCurrentTime', 0)
      this.$store.commit('audio/resetIncrementTime', 0)
      if (isNaN(this.duration)) this.$store.commit('audio/setDurationTime', 0)
      this.$store.commit('audio/resetProgressBar', 100)
      this.$store.commit('audio/resetMinCurrentTime', 0)
    },

    resetPlayState() {
      this.$store.commit('updateAudioPlayState', false)
    },

    // ラジオとプログレスバーの更新の実行
    resetAudio() {
      this.updateProgress()
      this.updateRadioData()

      // 遅延を入れないと更新不可
      setTimeout(() => { // audioタグの制約避け
        this.toPlay()
      }, 500)
      this.$store.commit('audio/setDurationTime', this.audioElement.duration)
    },

    // プログレスバーのシーク
    doSeek(e) {
      const maxWidth = document.getElementById('progress-bar').clientWidth
      const seekedTime = parseInt((parseInt(e.clientX - e.currentTarget.getBoundingClientRect().left) / maxWidth) * this.durationTime)
      this.toStop()
      this.$store.commit('audio/setCurrentTime', seekedTime)
      this.$store.commit('audio/setPausedTime', seekedTime)
      this.$store.commit('audio/resetIncrementTime', seekedTime)
      this.audioElement.currentTime = this.currentTime
      if (this.currentTime >= 60) this.$store.commit('audio/setMinCurrentTime', parseInt(this.currentTime / 60))
      if (this.currentTime < 60) this.$store.commit('audio/setMinCurrentTime', 0)
      this.toPlay()
    },

    toPlay() {
      if (!this.playState) this.audioElement.play()
      this.$store.commit('audio/updateIsOnAir', true)
      this.$store.commit('updateAudioPlayState', !this.$store.state.audioPlayState)
      if (this.currentTime > this.pausedTime) this.$store.commit('audio/setCurrentTime', this.pausedTime)
      this.$store.commit('updateShowTmpTime', false)
    },

    toStop() {
      if (this.playState) this.audioElement.pause()
      this.$store.commit('updateAudioPlayState', !this.$store.state.audioPlayState)
      this.$store.commit('audio/setPausedTime', this.currentTime)
      this.$store.commit('updateShowTmpTime', true)
    },

    toForward() {
      this.audioElement.pause()
      if (this.latestRadioNumber < this.radioData.length - 1) this.$store.commit('audio/setLatestRadioNumber', this.latestRadioNumber + 1)
      this.resetAudio()
    },

    toBack() {
      this.audioElement.pause()
      if (this.latestRadioNumber > 0) this.$store.commit('audio/setLatestRadioNumber', this.latestRadioNumber - 1)
      this.resetAudio()
    },

    // 早送り
    toUndo() {
      this.toStop()
      if ((this.currentTime - 30) < 0) {
        this.$store.commit('audio/setCurrentTime', 0)
        this.$store.commit('audio/setPausedTime', 0)
        this.$store.commit('audio/resetIncrementTime', 0)
        this.$store.commit('audio/resetProgressBar', 100)
      } else {
        this.$store.commit('audio/setCurrentTime', this.currentTime - 30)
        this.$store.commit('audio/setPausedTime', this.currentTime - 30)
        this.$store.commit('audio/resetIncrementTime', this.incrementTime - 30)
        this.$store.commit('audio/setProgressBar', -(30 / this.durationTime * 100))
      }
      this.audioElement.currentTime = this.currentTime
      if (this.currentTime >= 60) this.$store.commit('audio/setMinCurrentTime', parseInt(this.currentTime / 60))
      if (this.currentTime < 60) this.$store.commit('audio/setMinCurrentTime', 0)
      this.toPlay()
    },

    // 巻き戻し
    toRedo() {
      this.toStop()
      if ((this.currentTime + 30) > this.durationTime) {
        this.$store.commit('audio/setCurrentTime', this.durationTime)
        this.$store.commit('audio/setPausedTime', this.durationTime)
        this.$store.commit('audio/resetProgressBar', 0)
        this.toForward()
        this.toPlay()
      } else {
        this.$store.commit('audio/setCurrentTime', this.currentTime + 30)
        this.$store.commit('audio/setPausedTime', this.currentTime + 30)
        this.$store.commit('audio/resetIncrementTime', this.incrementTime + 30)
        this.$store.commit('audio/setProgressBar', 30 / this.durationTime * 100)
      }
      this.audioElement.currentTime = this.currentTime
      if (this.currentTime >= 60) this.$store.commit('audio/setMinCurrentTime', parseInt(this.currentTime / 60))
      this.toPlay()
    },

    // タイマーはsetIntervalを再度し直すことで対応
    // 1.25倍速
    toUpRate() {
      if (this.audioElement.playbackRate === 0.75) {
        // 0.75倍速=>通常
        this.audioElement.playbackRate = 1.0
        // タイマー更新
        this.$store.commit('audio/setPlayRate', 1.0)
      } else if (this.audioElement.playbackRate === 1.0) {
        // 通常=>1.25倍速
        this.audioElement.playbackRate = 1.25
        // タイマー更新
        this.$store.commit('audio/setPlayRate', 1.25)
      }
      window.clearInterval(this.intervalId)
      this.setAudioTimeCount()
    },

    // 0.75倍速
    toDownRate() {
      if (this.audioElement.playbackRate === 1.25) {
        // 1.25倍速=>通常
        this.audioElement.playbackRate = 1.0
        // タイマー更新
        this.$store.commit('audio/setPlayRate', 1.0)
      } else if (this.audioElement.playbackRate === 1.0) {
        // 通常=>0.75倍速
        this.audioElement.playbackRate = 0.75
        // タイマー更新
        this.$store.commit('audio/setPlayRate', 0.75)
      }
      window.clearInterval(this.intervalId)
      this.setAudioTimeCount()
    },

    // ボリュームスライダーの表示非表示切り替え
    handleVolSlider() {
      if (window.innerWidth > 768) this.showVolSlider = true
      else if (window.innerWidth <= 768) this.showVolSlider = false
      if (window.innerWidth > 768) this.smallWindow = false
      else if (window.innerWidth <= 768) this.smallWindow = true
    },

    // キーによるイベントハンドリング
    setKeydown() {
      window.addEventListener('keydown', (e) => {
        // P
        if (e.keyCode === 80 && e.shiftKey === true) {
          this.toBack()
        }

        // N
        if (e.keyCode === 78 && e.shiftKey === true) {
          this.toForward()
        }

        // l
        if (e.keyCode === 76) {
          this.toUndo()
        }

        // j
        if (e.keyCode === 74) {
          this.toRedo()
        }

        // m
        if (e.keyCode === 77) {
          this.updateVolMuted()
        }

        // >
        if (e.keyCode === 190 && e.shiftKey === true) {
          this.toUpRate()
        }

        // <
        if (e.keyCode === 188 && e.shiftKey === true) {
          this.toDownRate()
        }
      })
      return false
    }
  }
}
</script>

コメントアウトを参考に読んでいただきたいんですが、キーバインドの追加も一応プレイヤー内で行う形になっています(なってしまっています といった方が良いかもしれません)、改善しないと...

カウントアップには setIntervalclearInterval でゴニョゴニョやっています

ざっくりとした流れなんですが、

  • mountedaudioElement をストアから取得、値をセット
  • 再生中のステートを更新した際にカウントアップ開始
  • カウントアップに合わせてプログレスバーの状態を更新

という形になっています

また、ページ遷移しても再生が続くようにする部分の実装なんですが、以下のようにpages/list/list.vue 内で <nuxt-child>を用いてページを切り替えつつプレイヤーはそのまま表示し続けるような実装になっています

<template>
  <section class="container">
    <app-head />
    <nuxt-child />
    <transition name="pagination">
      <app-pagination v-if="showPagination" class="app-pagination" />
    </transition>
    <menu-icon class="menu-icon" />
    <div v-if="showPlayer" class="player">
      <audio-player />
    </div>
    <background-color class="background" />
  </section>
</template>

(こういうページの一部更新をサクッとできるのがプログレッシブなフレームワークだと楽ですよね...)

状態管理

主にプレイヤー部分の状態についてですが、こちらのPodcastサービスを参考に実装しています >

github.com

仕組みとしては

  • ページが読み込まれた際に createElement でaudio要素を作成、ストアにステートとして格納
  • 再生リストからアイテムが選ばれるたびにJSON APIの情報を作成したaudio要素に付与
  • 再生中の状態もすべてVuexストアで持つ

ような感じです

Vuex自体は基本的にモジュールではなく、index.js 内に state mutation action getter すべて書き下す形のクラシックなパターンで記述しました。

一部再生リストのアイテム選択やaudio要素関連の状態はそれぞれ filter.js audio.js に分けています。

以下にコード自体も載せておきます(結構汚いコードなのはご了承ください...)

store/index.js

export const state = () => ({
  audioPlayState: false,
  audioPlayCount: 0,
  radioPlayerState: false,
  audioSourceState: null,
  audioPausedTime: 0,
  showPlayer: false,
  showTmpTime: false,
  showPagination: false,
  isActiveMenuIcon: false
})

export const mutations = {
  updateAudioPlayState(state, flag) {
    state.audioPlayState = flag
  },
  updateAudioPlayCount(state, num) {
    state.audioPlayCount = num
  },
  updateRadioPlayerState(state, flag) {
    state.radioPlayerState = flag
  },
  updateAudioSourceState(state, source) {
    state.audioSourceState = source
  },
  updateAudioPauseTime(state, time) {
    state.audioPausedTime = time
  },
  updateShowPlayer(state, flag) {
    state.showPlayer = flag
  },
  updateShowTmpTime(state, flag) {
    state.showTmpTime = flag
  },
  updateShowPagination(state, flag) {
    state.showPagination = flag
  },
  updateIsActiveMenuIcon(state, flag) {
    state.isActiveMenuIcon = flag
  }
}

store/audio.js

export const state = () => ({
  latestRadioNumber: 0,
  radioTitle: null,
  radioDate: null,
  radioMembers: null,
  audioElement: null,
  audioVolume: 0.5,
  audioUrl: null,
  currentTime: 0,
  minCurrentTime: 0,
  pausedTime: 0,
  durationTime: 0,
  incrementTime: 0,
  progressBar: 100,
  playRate: 1.0
})

export const mutations = {
  resetIncrementTime(state, amount) {
    state.incrementTime = amount
  },
  resetProgressBar(state, amount) {
    state.progressBar = amount
  },
  resetMinCurrentTime(state, time) {
    state.minCurrentTime = time
  },
  setLatestRadioNumber(state, number) {
    state.latestRadioNumber = number
  },
  setRadioTitle(state, payload) {
    state.radioTitle = payload
  },
  setRadioDate(state, payload) {
    state.radioDate = payload
  },
  setRadioMembers(state, payload) {
    state.radioMembers = payload
  },
  setAudioElement(state, elem) {
    state.audioElement = elem
  },
  setAudioVolume(state, payload) {
    state.audioVolume = payload
  },
  setAudioUrl(state, payload) {
    state.audioUrl = payload
  },
  setCurrentTime(state, time) {
    state.currentTime = time
  },
  setMinCurrentTime(state, time) {
    state.minCurrentTime = time
  },
  setPausedTime(state, time) {
    state.pausedTime = time
  },
  setDurationTime(state, time) {
    state.durationTime = time
  },
  setIncrementTime(state, amount) {
    state.incrementTime += amount
  },
  setProgressBar(state, amount) {
    state.progressBar -= amount
  },
  setPlayRate(state, payload) {
    state.playRate = payload
  }
}

store/filter.js

export const state = {
  paginationKey: 'all',
  date: null,
  contents: null
}

export const mutations = {
  updatePaginationKey(state, payload) {
    state.paginationKey = payload
  },
  setDate(state, date) {
    state.date = date
  },
  setContents(state, payload) {
    state.contents = payload
  },
  addContents(state, payload) {
    state.contents.push(payload)
  }
}

export const actions = {
  async getContents({ commit, state }) {
    if (process.env.FLAG === 'dev') {
      const res = await this.$axios.get('http://localhost:3000/json/radio.json')
      if (state.date === null) {
        commit('setContents', res.data)
      } else {
        // filter処理
        commit('setContents', [])
        const ptn = new RegExp(state.date)
        for (let i = 0; i < res.data.length; i++) {
          if (res.data[i].date.match(ptn)) {
            commit('addContents', res.data[i])
          }
        }
        commit('setDate', null)
      }
    } else {
      const res = await this.$axios.get('https://tambourine-podcast.netlify.com/json/radio.json')
      if (state.date === null) {
        commit('setContents', res.data)
      } else {
        // filter処理
        commit('setContents', [])
        const ptn = new RegExp(state.date)
        for (let i = 0; i < res.data.length; i++) {
          if (res.data[i].date.match(ptn)) {
            commit('addContents', res.data[i])
          }
        }
        commit('setDate', null)
      }
    }
  }
}

Vuexの設計もっと上手にできるようになりたいのと、なるべくVuexに頼らない開発をやっていきたいです...

Google Driveからダウンロード(Google APIを叩く部分)

この部分はTypeScriptを用いて実装しています。

個人的にTSを学び始めた頃に力試しで書いたコードなので色々足らないですがあたたかく見守ってください...(もっと今ならマシなコードが書けるはずです)

以下がコードになります

process/googleDrive/client.ts

require('dotenv').config()

export {}

// const http = require('http')
import http from 'http' 
const url = require('url')
const opn = require('open')
const destroyer = require('server-destroy')
const { google } = require('googleapis')

const clientId = process.env.CLIENT_ID
const clientSecret = process.env.CLIENT_SECRET
const redirectUri = process.env.REDIRECT_URL

interface Client {
  // 調査出来ていないためany
  oAuth2Client: any
  authorizeUrl: any

  generateAuthUrl: {
    access_type: string
    scope: string
  }
}

export interface HttpServer extends http.Server {
}

class Client {
  constructor() {
    this.oAuth2Client = new google.auth.OAuth2(
      clientId,
      clientSecret,
      redirectUri
    )
  }

  setToken() {
    return (
      {
        access_token: process.env.ACCESS_TOKEN,
        refresh_token: process.env.REFRESH_TOKEN,
        scope: process.env.SCOPE,
        token_type: process.env.TOKEN_TYPE,
        expiry_date: process.env.EXPIRY_DATE
      }
    )
  }

  async authenticate(scopes: Array<string>) { // eslint-disable-line
    return new Promise((resolve, reject) => {
      if (!process.env.ACCESS_TOKEN) {
        // access_token未取得の場合実行
        this.authorizeUrl = this.oAuth2Client.generateAuthUrl({
          access_type: 'offline',
          scope: scopes.join(' ')
        })

        // HttpServer型で拡張予定、暫定any
        const server: any = http
          .createServer(async (req, res) => {
            try {
              if (req.url === undefined) return console.error()
              if (req.url.indexOf('/oauth2callback') > -1) {
                const qs = new url.URL(req.url, 'http://localhost:3000').searchParams
                res.end('Successed')
                server.destroy()
                const { tokens } = await this.oAuth2Client.getToken(qs.get('code'))

                // ここでconsole.log(tokens)すると以下のようにaccess_token諸々が見える、ローカル環境のみで行う
                /* {
                 *   access_token: 'XXXX',
                 *   refresh_token: 'XXXX', // 初回のみ発行 : https://github.com/googleapis/google-api-nodejs-client/issues/750#issuecomment-304521450
                 *   scope: 'hoge' + 'fuga',
                 *   token_type: 'XXXX',
                 *   expiry_date: 0000
                 * }
                */
                if (process.env.FLAG === 'dev') console.log(tokens)

                this.oAuth2Client.credentials = tokens
                resolve(this.oAuth2Client)
              }
            } catch (e) {
              reject(e)
            }
          })
          .listen(3000, () => {
            opn(this.authorizeUrl, { wait: false }).then((cp: any) => cp.unref())
          })
        destroyer(server)
      } else {
        // access_token取得済みで実行
        try {
          this.oAuth2Client.credentials = this.setToken()
          resolve(this.oAuth2Client)
        } catch (e) {
          reject(e)
        }
      }
    })
  }
}

module.exports = new Client()

process/googleDrive/download.ts

const fs = require('fs')
const { google } = require('googleapis')
const client = require('./client')

const folderId = process.env.FOLDER_ID
const params = {
  q: `'${folderId}' in parents and trashed = false`, // eslint-disable-line
}
const drive = google.drive({
  version: 'v3',
  auth: client.oAuth2Client
})

// https://developers.google.com/drive/api/v3/reference/files/get
export type RunDownloadResponse = {
  fileId: string
  acknowledgeAbuse?: boolean
  fields?: string
  supportsAllDrives?: boolean
  supportsTeamDrives?: boolean
  // 不明のため、any
  data: any
}

async function runAllDownload() {
  const res = await drive.files.list(params)
  const files = res.data.files
  if (files.length) {
    files.map((file: { name: string, id: string }) => {
      runDownload(file.name, file.id)
    })
  } else {
    console.log('None')
  }
}

function runDownload(fileName: string, id: string) {
  return drive.files
    .get({ fileId: id, alt: 'media' }, { responseType: 'stream' })
    .then((res: RunDownloadResponse) => {
      return new Promise((resolve, reject) => {
        const dest = fs.createWriteStream(`./dist/audio/${fileName}`)

        res.data
          .on('end', () => {
            console.log(`${fileName} is downloaded`)
            resolve()
          })
          .on('error', (err: any) => {
            console.log('error')
            reject(err)
          })
          .pipe(dest)
      })
    })
}

if (module === require.main) {
  const scopes = [
    'https://www.googleapis.com/auth/drive',
    'https://www.googleapis.com/auth/drive.appdata',
    'https://www.googleapis.com/auth/drive.file',
    'https://www.googleapis.com/auth/drive.metadata',
    'https://www.googleapis.com/auth/drive.metadata.readonly',
    'https://www.googleapis.com/auth/drive.photos.readonly',
    'https://www.googleapis.com/auth/drive.readonly'
  ]
  client
    .authenticate(scopes)
    .then(() => runAllDownload())
    .catch(console.error)
}

一部 anyが見え隠れしているんですが目をつぶりながら🙈進んでいただけると...(googleapis@types が見つからなかったので自力でどうにかしようとしています)

Google APIを叩く際にはこちらのライブラリを利用しています

www.npmjs.com

ただ、型定義が @types にない のでちょっとつらい感じになっています

実装自体は

developers.google.com

に載っているサンプルを参考に行いました。

OAuth2.0がちょっと慣れていなかったので手こずった部分もあります(refresh_tokenなど)が、どうにか実装できています

このコードを Netlify でビルドする際に ts-node で直に実行すると、dist/audio にmp3ファイルがダウンロードされます。

所感

  • もっとテストを作成しながらdocsも整備してちゃんとしたプロジェクトにしていきたい
  • 状態管理がかなり大変だった(Vuexのdispatchを空振るなど)
  • アニメーションが少ないので、UIをもっと細かく割ってReactで書き換えても良いかもしれない
  • スマホで再生するとかなりのレイテンシーが発生してしまっているので、スマホ版は別に実装したい

読んでいただいてありがとうございました