タンバリン大阪オフィスでエンジニアアルバイトをしている平井です。
こちらはJAMstackアドベントカレンダー19日目の記事です
内容
- 開発経緯
- Podcastについて
- 技術的な話
- 所感
開発経緯
タンバリン大阪オフィスでは tambourine radio という名前で不定期に社内向けのラジオを収録しています。
何回か収録を重ねるにつれて社員の方から「ラジオのアーカイブを聞けるpodcastサイトを作ろう」 と提案を頂いたので、インターン生(僕を含めて3人)でチーム開発をしました。
大まかな技術選定やチケットの作成は3人で行ったのですが、コーディングの段階に入って諸事情(他の方のインターン終了など)があり、僕がほぼ個人で制作する形になりました
Podcastについて
社内限定公開なので実際に見ていただくことはできないんですが、ざっくりとしたアプリの仕様等を画面のキャプチャと合わせて紹介していきたいと思います
まずトップ画面(PC版)がこんな感じです
さっそく適当に再生してみましょう、クリックするとこのようなモーダルが画面下部に出現します
これはオーディオプレイヤーです、早送りや巻き戻し、音量の調整(ミュート込み)ができます
次に左サイドのページネーションを操作してみましょう、2019-10
をクリックすると以下のように日付で絞り込みができます
この操作は再生を途切れさせることなく行うことができます。soundcloundやspotify web playerを参考にオーディオプレイヤーは制作しました。Nuxtだとこういった機能実装が簡単にできます( 技術的な話
セクションにて実装内容をお話します)
この他にもYouTubeを参考にしたショートカットキーバインド対応や、スマホ対応などもしています
以上ざっくりとした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を導入してみるのも良いかもしれないなと思ったり思わなかったりしています(現時点ではGitHubのwikiに僕が手書きしています)
・・・
次にこちらがアプリの簡易なアーキテクチャ図です
ラジオの音源の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
を使用して、デプロイ前にローカルで行っています(ここらへんデプロイ時に自動化できれば楽そう)
・・・
では実装の細かい話に入っていきます
ディレクトリ構成は以下の通りです(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からのダウンロード部分についてコードを交えながらお話していきます
UIコンポーネント
画像に振ってある順番で進めていきます
1. ページネーション
YYYY-MM
でラジオのアーカイブを分けています
以下は 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.json
の JSON API を取得- 取得完了後
getDate()
が呼び出され、日付をYYYY-MM
に成形 <template>
内で表示
という順で進んでいます。
簡単な正規表現と組み込み関数で contents/contents.json
にあるAPIのdata
を YYYY-MM-DD
から YYYY-MM
にしています。
また、 <template>
内での <nuxt-link>
に対するstyleなんですが、<style>
内で定義したものが適用できなかったため、data
の中で文字列として持っています(v-for
で展開した要素にそれぞれ(擬似クラスのhover
等)スタイルをうまく適用させる方法を模索中です...)
setDate()
と starFiltering()
はアイテム表示部分の実装に関連しているので次のセクションでまとめてお話します
2. ラジオのアイテム表示部分
アイテムをクリックするとプレイヤーが開き、再生が始まります。
ページネーション部分と連携しており、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.js
の latestRadioNumber
を更新する形で実装しています。
以下の部分で ラジオの再生とプログレスバーの状態を更新しています
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. プレイヤー
個人的にかなり力を入れて作ったのがこのプレイヤーの部分なのでちょっと詳しくお話したいと思います。
各アイコンをクリックすることで、
- 再生/停止
- 30秒早送り/巻き戻し
- アイテムの前/後
- 音量の調整/ミュート
こういった感じで操作できます
また、プログレスバーは自前で実装しています
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>
コメントアウトを参考に読んでいただきたいんですが、キーバインドの追加も一応プレイヤー内で行う形になっています(なってしまっています といった方が良いかもしれません)、改善しないと...
カウントアップには setInterval
と clearInterval
でゴニョゴニョやっています
ざっくりとした流れなんですが、
mounted
でaudioElement
をストアから取得、値をセット- 再生中のステートを更新した際にカウントアップ開始
- カウントアップに合わせてプログレスバーの状態を更新
という形になっています
また、ページ遷移しても再生が続くようにする部分の実装なんですが、以下のように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サービスを参考に実装しています >
仕組みとしては
- ページが読み込まれた際に
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を叩く際にはこちらのライブラリを利用しています
ただ、型定義が @types
にない のでちょっとつらい感じになっています
実装自体は
に載っているサンプルを参考に行いました。
OAuth2.0がちょっと慣れていなかったので手こずった部分もあります(refresh_token
など)が、どうにか実装できています
このコードを Netlify でビルドする際に ts-node で直に実行すると、dist/audio
にmp3ファイルがダウンロードされます。
所感
- もっとテストを作成しながらdocsも整備してちゃんとしたプロジェクトにしていきたい
- 状態管理がかなり大変だった(Vuexのdispatchを空振るなど)
- アニメーションが少ないので、UIをもっと細かく割ってReactで書き換えても良いかもしれない
- スマホで再生するとかなりのレイテンシーが発生してしまっているので、スマホ版は別に実装したい
読んでいただいてありがとうございました