プレイヤーを置きたい…

昔からAudio Assetsのページにプレイヤーを設置したかったんですが、audioタグではブラウザ毎にUIが異なり、カスタマイズも大変そうだったんで放置してました。しかしながら今回、リニューアルを機にこちらを導入してみました。

howler.jsはお手軽にサウンドを取扱いできるライブラリです。Web Audio APIのサポートとHTML5 Audioへのフォールバックを備えており、かなり高機能なことができるようですが、今回は以下の基本的な部分だけ備えた簡単なプレイヤーを作ってみます。

欲しい機能

  • スタート・ポーズ・ストップ
  • 再生時間表示
  • シークバー
  • ミュート

UIの作成

まずは再生関連のボタン。スタートはポーズとトグルにします。

<button class="toggle" @click="togglePlay(true)">
  <font-awesome-icon :icon="['fas', 'play']" class="fa-lg fa-fw"
    :class="playing ? 'is-hidden' : 'is-active'"/>
  <font-awesome-icon :icon="['fas', 'pause']" class="fa-lg fa-fw"
    :class="playing ? 'is-active' : 'is-hidden'"/>
</button>
<button class="stop" @click="stopAudio">
  <font-awesome-icon :icon="['fas', 'stop']" class="fa-lg"/>
</button>

playing は再生中かどうかのフラグです。これにより表示を切り替えます。ミュートボタンも同じような作りにします。

次に再生時間表示。seekdurationを使って表示しますが、いずれも秒での数値なんで、mm:ssの表示に変換する式を経由させます。

export default {
  ...
  computed: {
    getTime() {
      return (time) => {
        return Math.floor(time / 60) + ":" + ('00' + Math.floor(time % 60)).slice( -2 )
      }
    }
  },
  ...
}

{{ getTime(seek) }} / {{ getTime(duration) }} という感じにしておくと、再生中は自動で更新されていきます。本家のhowler.jsにはtimeupdateイベントがないらしいので実装したかったらsetIntervalなんかで頑張る必要があるみたいですが、こういうところはVueはラクですね。

シークバー

シークバーですが、作り方はいろいろあると思うんですが、今回はprogressタグとinput type="range"タグを組み合わせて作りました。最初は1つのコントローラーで何とかしようと思ったんですが、音切れが発生したりシーク中の挙動が不安定だったりと、なかなかうまくいかず。シークバーにはリアルタイム更新と入力受付の2つの要素がありますが、1つに詰め込もうとすると実装が大変ですね。

というわけで、以下のようにしました。

<progress :value="seek / duration" min="0" max="1"></progress>
<input v-model="pValue" type="range" min="0" max="1" step="0.01" class="seek-slider"/>

inputのopacityを0にしてprogressの上にサイズを合わせて重ねてます。opacityは0であってもコントロールを受け付けます。また、inputでバインドさせているpValueをwatchしておき、更新されたらsetProgressメソッドでprogressデータの更新、つまり再生位置を移動させてます。

export default {
  ...
  watch: {
    pValue(n) {
      this.setProgress(n * 1)
    }
  },
  ...
}

progressは seek / duration の表示を行っているだけで、progressデータと同義です。2つの要素をピッタリ重ねているため、再生中プログレスバーが進みながらも入力を受け付けているように見えるUIとなりました。

※たしか、progressデータをそのままprogressのvalueにすると、再生中のシークバー移動の際に再生による更新と干渉してスムーズにドラッグできなかった記憶が。それでこんな回りくどい方法を取ったと思います。このあたりの実装はもっと良い方法があるかもしれません。

とにかく、これで概ね希望通りのUIができました。あとはcssでちょこちょこと仕上げて完成です。

二重再生の回避

今回は1つのページの中に複数のプレイヤーを並べているのですが、再生時にすでに別のプレイヤーが再生されていたらそれを一時停止してから再生したいと思います。親子間の通信になりますので$emitを利用して子であるAudioPlayerコンポーネントから通知し、それを受け取った親の方から各AudioPlayerの再生を一時停止する方法をとります。

再生トグルは自身で発火したかどうかの引数をとり、かつ再生中でなければ beforeStart が発火します。

export default {
  ...
  methods: {
    togglePlay(flg = false) {
      this.self = flg
      if (this.self && !this.playing) {  // 自身でイベント発火かつ再生中でないは通知
        this.$emit('beforeStart')
      }
      this.togglePlayback()
      this.self = false
    },
  },
  ...
}

AudioPlayerコンポーネントは親の方では以下のように設置してます。

<AudioPlayer ref="audioPlayer"
  :sources="[path.slug + '.mp3']"
  :loop="false"
  @beforeStart="beforePlay">
</AudioPlayer>

beforeStart が発火した際は beforePlay が呼び出されるようにしておき、そこで各プレイヤーの一時停止メソッドを操作します。ちなみに再生を開始したプレイヤーまで一時停止メソッドを実行するため、フラグが立っていれば一時停止は行わないようにします。これは、再生を開始したプレイヤーがすぐに一時停止してしまう可能性をを防ぐための措置です(多分やらなくとも大丈夫ですが)。

export default {
  ...
  methods: {
    pauseAudio() {
      if (!this.self) {  // 親からの一時停止指示のトリガーが自身でなければ停止
        this.pause();
      }
    },
  ...
  }
}

プレイヤーのリスト化

親ページに設置したプレイヤーは、refを使って配列化しておきます。Vue2ではv-forの中でref属性を記述すると、対応する$emitプロパティに参照の配列が入ります。これにより beforePlay メソッドにおいて以下のように書くと、各プレイヤーのメソッドを直接叩けます。

export default {
 ...
  methods: {
    beforePlay() {
      Object.keys(this.$refs.audioPlayer).forEach((i) => {
        this.$refs.audioPlayer[i].pauseAudio()
      })
    }
  }
  ...
}

シークバーの作成に苦戦しましたが、わりにすんなりと基本的なAudioPlayerコンポーネントを作成することができました。親からの制御を利用しないのであれば、コンポーネントを置くだけで利用できるところが良いですね。

関連記事

Nuxt3への移行を機にhowler.jsをそのまま使ってみました。