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