How to preload Howler Audio *without* creating context (Audio Context cannot be created before user gesture)

277 Views Asked by At

I have a 'working' solution, but the problem is the UI is immediately available, even if the audio is not available. My intended use-case is this:

User views a song page. The song loads, and while loading, a loading screen shows. If the audio doesn't exist, or there's an error - an error screen shows up.

If the song does load, it sets up the audio context and the Howler instance.

I set up the player to show the UI immediately, and on play button it sets up the instance. The problem is that it is terrible UX. Play doesn't 'play' as much as it loads the file and then plays it. Sometimes there can be up to a second befor the audio plays.

Is there a way to preload the audio file with Howler, and to defer the creation of the audio context until after the user pressed the play button?

Here is my SongPlayer svelte file:

<script lang="ts">
  import type { Howl } from 'howler'
  import { browser } from '$app/environment'
  import 'iconify-icon'
  import { onDestroy, onMount } from 'svelte'
    import { fade } from 'svelte/transition'

  // Components
  import SocialShare from './SocialShare.svelte'

  // Stores
  import { mobileDevice } from '$src/stores/mobileDevice'

  interface CustomHowl extends Howl {
    _sounds: Array<{
      _node: HTMLAudioElement
    }>
  }

  // Vars
  let loaded = false
  let loadError = false
  export let song_id: string = ""
  export let title: string = ""
  export let pathname: string = ""

  // Canvas
  let canvas: HTMLCanvasElement
  let canvasCtx: CanvasRenderingContext2D | null
  $: canvasCtx = canvas?.getContext('2d') ?? null
  $: title = title ? decodeURI(title) : ""

  // Controls
  let playing = false
  let progress = 0
  let volume = 1
  let loop = false
  let seeking = false

  // Howler vars
  let Howl: typeof import('howler').Howl
  let song: CustomHowl
  let analyser: AnalyserNode
  let ctx: AudioContext

  $: song && song.volume(volume)
  
  // Globals
  const handlePlay = () => {
    if (!song && Howl) {
      initHowl(Howl)
    }
    playing ? song.pause() : song.play()
  }

  const initHowl = (Howl: any) => {
    // The Howl
    song = new Howl({
          xhr: {
            method: 'GET',
            headers: {
              'Access-Control-Allow-Origin': '*',
              'Content-Type': 'audio/mpeg',
            },
          },
          format: ['mp3'],
          pool: 1,
          src: "workingdomain.com/" + song_id + ".mp3",
          volume: volume,
          loop,
          onload: () => {
            loaded = true
            // Audio Context
            ctx = Howler.ctx
            analyser = ctx.createAnalyser()
            analyser.fftSize = 128
            Howler.masterGain.connect(analyser)
          },
          onloaderror: () => {
            loadError = true
          },
          onend: () => {
            playing = false
            progress = 0
            canvasCtx?.clearRect(0, 0, canvas.width, canvas.height)
            if (loop) song.play()
          },
          onpause: () => {
            playing = false
          },
          onplay: () => {
            playing = true
            requestAnimationFrame(tick)
          },
        }) as CustomHowl
  }

  onMount(async () => {
    canvasCtx = canvas?.getContext('2d') ?? null
    if (browser) {
      ({ Howl } = await import('howler'))
    }
  })

  onDestroy(() => {
    song && song.unload()
  })

  const tick = () => {
    if (!seeking) progress = roundDecimal(song.seek() / song.duration())
    if (analyser && canvas && canvasCtx ) {
      let buffer = analyser.frequencyBinCount
        let data = new Uint8Array(buffer)
        let width = canvas.width
        let height = canvas.height
        analyser.getByteFrequencyData(data)
        let barWidth = (width / buffer) * 2
        let barHeight
        let grd = canvasCtx.createLinearGradient(0, height, 0, height / 2)
        grd.addColorStop(0, 'rgba(0,0,200,0.2)')
        grd.addColorStop(1, 'rgba(255,0,0,0.2)')

      if (playing || song.playing()) {
        canvasCtx.clearRect(0, 0, width, height)
        let x = 0

        for ( let i = 0; i < buffer; i++) {
          barHeight = data[i]
          canvasCtx.fillStyle = grd
          canvasCtx.fillRect(x, height, barWidth, -(barHeight / 2))
          x += barWidth + 1
        }
      } else {

      }
      requestAnimationFrame(tick)
    }
  }

  const roundDecimal = (num: number | string): number => {
    if (typeof num === 'string') {
      return Math.round(Number(num) * 1000) / 1000
    }
    else {
      return Math.round(num * 1000) / 1000
    }
  }

  const handleDrag = (e: Event & { currentTarget: HTMLInputElement }) => {
    seeking = true
    progress = roundDecimal(e.currentTarget.value)
  }

  const handleSeek = (e: Event & { currentTarget: HTMLInputElement }) => {
    seeking = false
    const currentSeek = Number(e.currentTarget.value) * song.duration();
    song.seek(currentSeek);
  }

  const handleSkip = (offset: number) => {
    seeking = false
    let pos = Math.max(0, Math.min((offset + progress * song.duration()), song.duration()))
    if (!playing || !song.playing()) progress = pos / song.duration()
    song.seek(pos)
  }

  const handleVolume = (e: Event & { currentTarget: HTMLInputElement }) => {
    volume = Math.round(Number(e.currentTarget.value) * 100) / 100
  }
</script>

{#if browser && song_id && !loadError}
<div in:fade class="relative flex flex-col w-full max-w-3xl m-auto p-10 bg-zinc-900 rounded-xl">
  <canvas bind:this={canvas} id="visualizer" class="z-10 absolute left-0 right-0 top-0 bottom-0 w-full h-full rounded-xl"></canvas>
  <img src="/logos/thundermp3-logo-text.svg" alt="ThunderMP3 Logo" class="z-50 h-auto max-h-16 mb-10 self-start" />
  {#if !!title}
    <h1 class="break-words self-center font-bold text-[calc(1vmin+15px)] mb-8">{title}</h1>
  {/if}
  <input class="w-full mb-5 z-50" type="range" min="0" max="1" step={0.001} bind:value={progress} on:mousedown={handleDrag} on:mouseup={handleSeek} on:touchstart={handleDrag} on:touchend={handleSeek} />
  <div class="z-50 flex justify-between">
    <button class="opacity-1 active:opacity-50 hover:opacity-50 transition-all duration-150 ease-in-out" on:click={() => handleSkip(-10)}>
      <iconify-icon icon="fluent:skip-back-10-20-regular" class="text-4xl" />
    </button>
    <button class="opacity-1 active:opacity-50 hover:opacity-50 transition-all duration-150 ease-in-out" on:click={handlePlay}>
      {#if playing}
      <iconify-icon in:fade icon="fad:pause" class="text-4xl"/>
      {:else}
      <iconify-icon in:fade icon="fad:play" class="text-4xl"/>
      {/if}
    </button>
    <button class="opacity-1 active:opacity-50 hover:opacity-50 transition-all duration-150 ease-in-out" on:click={() => handleSkip(10)}>
      <iconify-icon icon="fluent:skip-forward-10-20-regular" class="text-4xl" />
    </button>
    {#if !$mobileDevice}
      <div class="flex items-center">
        <input type="range" min="0" max="1" step={0.001} bind:value={volume} on:change={handleVolume}/>
        <iconify-icon icon="fad:speaker" class="text-4xl" />
      </div>
    {/if}
  </div>
</div>
<SocialShare {pathname} {title}/>
{:else if loadError}
  <div class="flex flex-col items-center justify-center p-10 m-auto mb-3">
    <p>There was an error loading the song. Please try again later.</p>
  </div>
{/if}

Here is a 'working' version (with the audio context error): https://thundermp3.com/song/5b166768-d7dc-4162-bf25-6813d83367f3?title=Test%20Audio

Here is a version that defers the loading until press. You'll see there's a pause. I'd like to avoid this: https://howler-context-fix.thundermp3.pages.dev/song/4baf58c7-0553-4288-ab14-7dbc6c88998c?title=Test%20Title%20V1

0

There are 0 best solutions below