vue-howlerが未対応

以前の記事ではAudio Playerの作成にvue-howlerを使ってましたが、どうやらNuxt3には対応してないとのことですので、そのままhowler.jsを使ってみました。

インストール

npm i howler
# or
yarn add howler

こちらも入れておきましょう。

npm i @types/howler
# or
yarn add @types/howler

コンポーネント作り

今回は再生と一時停止のトグルとシークバーのみのシンプルなデザインに変更、以下の部分をコンポーネントにしました。

今回のモデル

なお、AudioSourceや各オプションを渡して単一のプレイヤーにするという点は前回と同様。表示部は以下の様になります。

<template>
  <div class="player">
    <button class="toggle" @click="!playerState.playing ? play(true) : pause()">
      <font-awesome-icon :icon="['fas', 'play']" class="fa-lg play-icon" :class="playerState.playing ? 'is-hidden' : 'is-active'" />
      <font-awesome-icon :icon="['fas', 'pause']" class="fa-lg pause-icon" :class="playerState.playing ? 'is-active' : 'is-hidden'" />
    </button>
    <div class="contents">
      <h4 class="title">{{ playerState.title }}</h4>
      <div class="seek-block">
        <div class="current-time">{{ playerState.currentTime }}</div>
        <div class="bar">
          <progress :value="playerState.progress" min="0" max="1"></progress>
          <input
            v-model="value"
            type="range"
            min="0"
            max="1"
            step="0.01"
            class="seek-slider"
          />
        </div>
        <div class="sound-time">{{ playerState.soundTime }}</div>
      </div>
    </div>
  </div>
</template>

受け取るデータをPropsに。

<script setup lang="ts">
import { Howl } from 'howler'

interface Props {
  src: string[]
  title?: string
  volume?: number
  html5?: boolean
  loop?: boolean
  preload?: boolean
  autoplay?: boolean
  mute?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  src: () => [],
  title: '',
  volume: 1.0,
  html5: false,
  loop: false,
  preload: true,
  autoplay: false,
  mute: false,
})
...
</script>

templateで利用するプレイヤーの各状態はreactiveでまとめてます。今回はvolumeは利用しないのですが、今後使うかもしれないのでとりあえず書いておきます。

const playerState = reactive({
  title: props.title,
  playing: false,
  isMuted: false,
  volume: props.volume,
  progress: 0,
  currentTime: '0:00',
  soundTime: '0:00',
})

オプションを渡してインスタンスを生成。

const sound = ref<Howl>()

sound.value = new Howl({
  src: props.src,
  volume: props.volume,
  html5: props.html5,
  loop: props.loop,
  preload: props.preload,
  autoplay: props.autoplay,
  mute: props.mute,
  onload: () => {
    getDuration()
  },
  onend: () => {
    clearTimer()
    playerState.playing = false
  },
})

@types/howler で onload 等の各イベントは定義されてますんで、スッキリと書けます。 onload では曲の長さを取得、曲が最後まで再生された時に発火する onend ではTimerをクリアさせてます。[1] durationは秒で取得されますんで、分と秒の時間表示に変換。

const formatTime = (sec: number) => {
  return `${Math.floor(sec / 60)}:${('00' + Math.floor(sec % 60)).slice(-2)}`
}

const getDuration = () => {
  _duration = sound.value?.duration() || 0
  playerState.soundTime = formatTime(_duration)
}

const clearTimer = () => {
  if (intervalId) {
    clearInterval(intervalId)
  }
}

今回も再生やポーズのトグルでイベント発火前に行う動作が必要だったんで、以下のクリックイベントにて別途書いてます。そのトグルボタンのクリックイベントですが、再生するとプログレスバーや再生時間を更新していくためのsetIntervalを設定します。大した精度もいらないので200ms(5Hz)としてます。なお、ポーズした際もonend同様Timerをクリア。

const play = (flg = false) => {
  isSelf = flg
  if (isSelf) {
    emit('beforePlay')
  }
  sound.value?.play()
  intervalId = setInterval(setProgress, 200)
  playerState.playing = true
  isSelf = false
}

const pause = () => {
  if (!isSelf) {
    sound.value?.pause()
    clearTimer()
    playerState.playing = false
  }
}

setIntervalで発火するsetProgressで何をやってるかというと、以下の様に現在の再生位置を取得し0~1の値に変換。プログレスバーを <progress :value="playerState.progress" min="0" max="1"></progress> としているので、0~1の間で更新されていきます。

const setProgress = () => {
  const seek = sound.value?.seek() || 0
  if (seek) {
    playerState.progress = (seek / _duration) || 0
  }
  playerState.currentTime = formatTime(seek)
}

シークバーの作成

前回同様、見た目はプログレスバーが進んでいきながらもシークできるような感じにします。後述するget、setにてinputのみでプログレスバーとインプット両方の機能を持たせることもできましたが、再生時のバー更新がなめらかに進んでいかなかったので表示と入力を分ける作りはそのままにしました。

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

Nuxt3はv-modelの書き方が変更されましたね。

const value = computed({
  get(): number {
    return playerState.progress
  },
  set(value: number) {
    sound.value?.seek(_duration * value)
    playerState.progress = value
    playerState.currentTime = formatTime(value * _duration)
  },
})

上記の通り、setProgressによる更新処理にinputが割り込めるという仕組みにしてます。

二重再生の回避

これも前回やりましたが、再生前に他のプレイヤーが再生中であれば一時停止させます。 const emit = defineEmits(['beforePlay']) とし、再生前にイベント発火。親に伝えます。

親であるページでは、このプレイヤーは

<li v-for="audioData in audioLists[0].list" :key="audioData._id">
  <AudioPlayer
    :ref="setPlayers"
    :title="audioData.data.title"
    :src="[`audio/${audioData.data.slug}.mp3`]"
    @before-play="pausePlayer"
  />
  <DownloadButton
    :slug="audioData.data.slug"
    :ogg="audioData.data.ogg"
    :loop="audioData.data.loop"
    :sli="audioData.data.sli"
  />
</li>

となってまして、 before-play 時には pausePlayer を実行。

refによる参照

Composition APIでは$refsが使えません。配列で取得したい場合はrefに関数をバインドさせるそうなんで、以下の様にセッティング。

<script>
...
const players = []

onBeforeUpdate(() => {
  players.value = []
})

const setPlayers = (el) => {
  if (el) {
    players.push(el)
  }
}

const pausePlayer = () => {
  players.forEach((el) => {
    el.pause()
  })
}
</script>

子コンポーネントであるプレイヤーで defineExpose({ pause }) させておき、ポーズを実行できるようにしておきます。

まとめ

もともとvue-howlerがmixinの形式だったためvue-howlerが担ってた箇所を改めて作るという程度で、そんなに違和感なく作り変えることができました。

次はSoundCloudみたいにプログレスバーの部分を波形表示にしたいなと思ってますんで、howlerの機能をさらに使っていくか、wavesurfer.jsに変更するか検討します。


  1. 再生中にページ遷移するケースも考慮し、 onBeforeMount でも併せてクリアさせます。 ↩︎