Svelte Audio UI
DocsParticles
0
Overview
  • Introduction
  • Get Started
Components
  • Audio Player
  • Audio Provider
  • Audio Queue
  • Audio Track
  • Audio Playback Speed
UI
  • Fader
  • Knob
  • Slider
  • Sortable List
  • XY Pad
Libs
  • Audio Store
  • HTML Audio
Resources
  • llms.txt
  • llms-full.txt

On This Page

InstallationUsageAPI ReferencePropsTrack ShapeHow It WorksLifecycleReactive $effect SyncError HandlingNext Track PreloadingState PersistenceNotes

Audio Provider

Previous Next

Svelte 5 provider component that manages audio playback lifecycle, state synchronization, and error handling.


AudioProvider is the engine behind every audio component. It initializes the HTML audio element, registers all event listeners, syncs playback state with the audioStore, handles errors and retries, preloads the next track, and persists state to localStorage.

It is required. All audio components depend on it.

Installation

pnpm dlx shadcn-svelte@latest add https://svelte-audio-ui.vercel.app/r/provider.json
npx shadcn-svelte@latest add https://svelte-audio-ui.vercel.app/r/provider.json
npx shadcn-svelte@latest add https://svelte-audio-ui.vercel.app/r/provider.json
bun x shadcn-svelte@latest add https://svelte-audio-ui.vercel.app/r/provider.json

Copy and paste the following code into your project.

<script lang="ts">
  import { onMount } from "svelte";
  import type { Snippet } from "svelte";
  import { htmlAudio } from "$lib/html-audio.js";
  import type { Track } from "$lib/html-audio.js";
  import { audioStore, calculateNextIndex } from "$lib/audio-store.svelte.js";

  interface Props {
    tracks?: Track[];
    children: Snippet;
  }

  let { tracks = [], children }: Props = $props();

  // ─── Constants ─────────────────────────────────────────────────────────────
  const MAX_ERROR_RETRIES = 3;
  const ERROR_RETRY_DELAY = 1000;
  const THROTTLE_INTERVAL = 100;
  const MIN_UPDATE_THRESHOLD = 0.5;

  // ─── Non-reactive refs (no $state — mutations must not trigger re-renders) ──
  let preloadAudio: HTMLAudioElement | null = null;
  let errorRetryCount = 0;
  let lastSeekTime = 0;
  let lastUpdateTime = 0;
  let prevTrackId: string | number | undefined = undefined;

  // ─── Sync tracks prop → store ───────────────────────────────────────────────
  $effect(() => {
    if (!tracks || tracks.length === 0) return;
    const q = audioStore.queue;
    const changed =
      q.length === 0 ||
      q.length !== tracks.length ||
      q.some((t, i) => t.id !== tracks[i]?.id);
    if (changed) {
      audioStore.queue = tracks;
      if (!audioStore.currentTrack) audioStore.currentTrack = tracks[0] ?? null;
      if (audioStore.currentQueueIndex === -1) audioStore.currentQueueIndex = 0;
    }
  });

  // ─── Helpers ────────────────────────────────────────────────────────────────
  function forceTimeUpdate() {
    const audio = htmlAudio.getAudioElement();
    if (!audio) return;
    audioStore.syncTime(audio.currentTime, audio.duration || 0);
    lastUpdateTime = Date.now();
  }

  function throttledTimeUpdate() {
    const now = Date.now();
    if (now - lastUpdateTime < THROTTLE_INTERVAL) return;
    lastUpdateTime = now;
    const audio = htmlAudio.getAudioElement();
    if (!audio) return;
    if (Math.abs(audioStore.currentTime - audio.currentTime) > MIN_UPDATE_THRESHOLD) {
      audioStore.syncTime(audio.currentTime, audio.duration || 0);
    }
  }

  function preloadTrack(song: Track) {
    if (!preloadAudio || preloadAudio.src === song.url) return;
    try {
      preloadAudio.src = song.url;
      preloadAudio.preload = "auto";
      preloadAudio.load();
    } catch {
      if (preloadAudio) preloadAudio.src = "";
    }
  }

  function preloadNextTrack() {
    if (!preloadAudio) return;
    const nextIdx = calculateNextIndex({
      queue: audioStore.queue,
      currentQueueIndex: audioStore.currentQueueIndex,
      shuffleEnabled: audioStore.shuffleEnabled,
      repeatMode: audioStore.repeatMode,
    });
    if (nextIdx === -1 || nextIdx >= audioStore.queue.length) return;
    const next = audioStore.queue[nextIdx];
    if (!next || next.id === audioStore.currentTrack?.id) return;
    preloadTrack(next);
  }

  async function retryPlayback(audio: HTMLAudioElement): Promise<boolean> {
    if (errorRetryCount >= MAX_ERROR_RETRIES) return false;
    errorRetryCount++;
    const delay = 2 ** (errorRetryCount - 1) * ERROR_RETRY_DELAY;
    await new Promise((r) => setTimeout(r, delay));
    if (typeof navigator !== "undefined" && !navigator.onLine) return false;
    try {
      const currentTime = audio.currentTime;
      const wasPlaying = !audio.paused;
      if (audioStore.currentTrack) {
        await htmlAudio.load({ url: audioStore.currentTrack.url, startTime: currentTime });
        if (wasPlaying) await htmlAudio.play();
      }
      return true;
    } catch {
      return false;
    }
  }

  // ─── Main setup (event listeners on HTMLAudioElement) ──────────────────────
  onMount(() => {
    htmlAudio.init();

    preloadAudio = new Audio();
    preloadAudio.muted = true;
    preloadAudio.preload = "none";

    const audio = htmlAudio.getAudioElement();
    if (!audio) return;

    const ctrl = new AbortController();
    const { signal } = ctrl;

    // ── Event handlers ────────────────────────────────────────────────────────

    const handlePlay = () => {
      errorRetryCount = 0;
      htmlAudio.setPlaybackRate(audioStore.playbackRate);
      audioStore.isPlaying = true;
      audioStore.isLoading = false;
      audioStore.isBuffering = false;
      forceTimeUpdate();
      requestAnimationFrame(forceTimeUpdate);
      setTimeout(forceTimeUpdate, 50);
      preloadNextTrack();
    };

    const handlePause = () => {
      forceTimeUpdate();
      audioStore.isPlaying = false;
      audioStore.isBuffering = false;
    };

    const handleError = async (e: Event) => {
      let message = "Unknown audio error";
      let recoverable = false;
      if (audio.error) {
        const { code } = audio.error;
        if (code === MediaError.MEDIA_ERR_ABORTED) {
          message = "Playback cancelled";
          recoverable = true;
        } else if (code === MediaError.MEDIA_ERR_NETWORK) {
          message = "Network error";
          recoverable = true;
        } else if (code === MediaError.MEDIA_ERR_DECODE) {
          message = "Audio file decoding error";
          recoverable = false;
        } else if (code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) {
          message = "File/network loading error (Code 4)";
          recoverable = true;
        } else {
          message = `Unknown error (${code})`;
          recoverable = true;
        }
      } else if (e instanceof ErrorEvent) {
        message = e.message;
        recoverable = true;
      }

      if (recoverable && errorRetryCount < MAX_ERROR_RETRIES) {
        if (await retryPlayback(audio)) return;
      }

      audioStore.isPlaying = false;
      audioStore.isLoading = false;
      audioStore.isBuffering = false;
      audioStore.isError = true;
      audioStore.errorMessage =
        recoverable && errorRetryCount >= MAX_ERROR_RETRIES
          ? `Failed after ${MAX_ERROR_RETRIES} attempts: ${message}`
          : message;
    };

    const handleEnded = async () => {
      audioStore.isPlaying = false;
      audioStore.isBuffering = false;
      const audioDuration = audio.duration || 0;
      const isLiveStream = htmlAudio.isLive(audioDuration);

      if (audioStore.currentTrack && isLiveStream) {
        audioStore.isError = true;
        audioStore.errorMessage = "Live stream connection lost";
        return;
      }

      if (audioStore.repeatMode === "one" && audioStore.currentTrack) {
        try {
          await htmlAudio.load({
            url: audioStore.currentTrack.url,
            startTime: 0,
            isLiveStream,
          });
          await htmlAudio.play();
          audioStore.currentTime = 0;
          audioStore.progress = 0;
          return;
        } catch {
          /* fall through to next */
        }
      }

      audioStore.handleTrackEnd();
    };

    const handleLoadStart = () => {
      audioStore.isLoading = true;
      audioStore.isBuffering = false;
      audioStore.isError = false;
      audioStore.errorMessage = null;
    };

    const handleCanPlay = () => {
      audioStore.isLoading = false;
      audioStore.isBuffering = false;
      audioStore.duration = audio.duration || 0;
      audioStore.isError = false;
      audioStore.errorMessage = null;
    };

    const handleWaiting = () => {
      audioStore.isBuffering = true;
      audioStore.isLoading = false;
    };
    const handlePlaying = () => {
      audioStore.isLoading = false;
      audioStore.isBuffering = false;
      audioStore.isPlaying = true;
    };
    const handleDuration = () => {
      audioStore.duration = audio.duration || 0;
    };
    const handleVolume = () => {
      audioStore.volume = audio.volume;
      audioStore.isMuted = audio.muted;
    };
    const handleBufferUpdate = (e: Event) => {
      if (e instanceof CustomEvent && e.detail?.bufferedTime !== undefined) {
        audioStore.bufferedTime = e.detail.bufferedTime;
      }
    };

    // ── Register ──────────────────────────────────────────────────────────────
    audio.addEventListener("play", handlePlay, { signal });
    audio.addEventListener("pause", handlePause, { signal });
    audio.addEventListener("playing", handlePlaying, { signal });
    audio.addEventListener("waiting", handleWaiting, { signal });
    audio.addEventListener("loadstart", handleLoadStart, { signal });
    audio.addEventListener("canplay", handleCanPlay, { signal });
    audio.addEventListener("canplaythrough", handleCanPlay, { signal });
    audio.addEventListener("timeupdate", throttledTimeUpdate, { signal });
    audio.addEventListener("durationchange", handleDuration, { signal });
    audio.addEventListener("loadedmetadata", handleDuration, { signal });
    audio.addEventListener("volumechange", handleVolume, { signal });
    audio.addEventListener("ended", handleEnded, { signal });
    audio.addEventListener("error", handleError, { signal });
    htmlAudio.addEventListener("bufferUpdate", handleBufferUpdate);

    // ── Restore persisted state ───────────────────────────────────────────────
    (async () => {
      if (!audioStore.currentTrack || audioStore.currentTime <= 0) return;
      const track = audioStore.currentTrack;
      const audioDur = audio.duration || audioStore.duration || 0;
      const isLive = htmlAudio.isLive(audioDur);
      const startTime = isLive ? 0 : audioStore.currentTime;
      prevTrackId = track.id; // suppress the track-change effect on restore
      try {
        audioStore.isLoading = true;
        await htmlAudio.load({ url: track.url, startTime, isLiveStream: isLive });
        htmlAudio.setVolume({ volume: audioStore.volume });
        htmlAudio.setMuted(audioStore.isMuted);
        htmlAudio.setPlaybackRate(audioStore.playbackRate);
        audioStore.isLoading = false;
        audioStore.isPlaying = false;
      } catch {
        audioStore.isError = true;
        audioStore.errorMessage = "Error restoring audio state";
        audioStore.isPlaying = false;
        audioStore.isLoading = false;
        audioStore.isBuffering = false;
      }
    })();

    return () => {
      ctrl.abort();
      htmlAudio.removeEventListener("bufferUpdate", handleBufferUpdate);
      if (preloadAudio) {
        preloadAudio.src = "";
        preloadAudio = null;
      }
    };
  });

  // ─── Reactive sync effects ──────────────────────────────────────────────────

  /** Sync playback rate to audio element */
  $effect(() => {
    htmlAudio.setPlaybackRate(audioStore.playbackRate);
  });

  /** Load new track and play when currentTrack changes */
  $effect(() => {
    const track = audioStore.currentTrack;
    const trackId = track?.id;
    if (!track || trackId === prevTrackId) return;
    prevTrackId = trackId;

    const audio = htmlAudio.getAudioElement();
    if (!audio) return;

    htmlAudio
      .load({ url: track.url, startTime: 0, isLiveStream: false })
      .then(() => {
        audioStore.isLoading = false;
        if (audioStore.isPlaying) return htmlAudio.play();
      })
      .catch(() => {
        audioStore.isLoading = false;
        audioStore.setError("Error loading track");
      });
  });

  // Play/pause is now synchronously handled by audioStore.play() and .pause()

  /** Seek when store currentTime is updated by user (not by syncTime) */
  $effect(() => {
    const currentTime = audioStore.currentTime;
    const audio = htmlAudio.getAudioElement();
    if (!audio) return;
    const diff = Math.abs(audio.currentTime - currentTime);
    if (diff > 0.1 && currentTime !== lastSeekTime) {
      lastSeekTime = currentTime;
      htmlAudio.setCurrentTime(currentTime);
    }
  });

  /** Sync volume changes */
  $effect(() => {
    htmlAudio.setVolume({ volume: audioStore.volume });
  });

  /** Sync mute changes */
  $effect(() => {
    htmlAudio.setMuted(audioStore.isMuted);
  });

  /** Reset preload audio when queue changes drastically */
  $effect(() => {
    const qLen = audioStore.queue.length; // track reactively
    if (qLen === 0 && preloadAudio) preloadAudio.src = "";
  });

  /** Persist state to localStorage */
  $effect(() => {
    // Accessing each field registers it as a dependency
    void [
      audioStore.currentTrack,
      audioStore.queue.length,
      audioStore.volume,
      audioStore.isMuted,
      audioStore.playbackRate,
      audioStore.repeatMode,
      audioStore.shuffleEnabled,
      audioStore.currentTime,
      audioStore.insertMode,
      audioStore.currentQueueIndex,
    ];
    audioStore.saveToStorage();
  });
</script>

{@render children()}

Usage

The recommended place to mount AudioProvider is in your layout file so all pages share the same playback state:

<script lang="ts">
  import { AudioProvider } from "$lib/components/ui/audio/provider/index.js";
 
  let { children } = $props();
 
  const tracks = [
    {
      id: "1",
      title: "My Track",
      artist: "Artist Name",
      url: "https://example.com/audio.mp3",
    },
  ];
</script>
 
<AudioProvider {tracks}>
  {@render children()}
</AudioProvider>

API Reference

Props

Prop Type Default Description
tracks Track[] [] Initial list of tracks to load into the queue.
children Snippet — Required. The content to render inside the provider.

Track Shape

Each track in the tracks array must follow this shape:

interface Track {
  id: string | number;
  title: string;
  artist?: string;
  url: string; // URL to the audio file or stream
  cover?: string; // Optional album art URL
  duration?: number; // Optional pre-known duration in seconds
}

How It Works

AudioProvider coordinates three layers:

  1. htmlAudio — a singleton that holds the actual HTMLAudioElement
  2. audioStore — a Svelte 5 reactive class instance that holds all playback state
  3. AudioProvider — the glue layer that wires DOM events → store updates and store changes → DOM mutations
tracks prop → audioStore.queue
audioStore state changes → htmlAudio (DOM)
htmlAudio DOM events → audioStore state
audioStore → localStorage

Lifecycle

On mount, AudioProvider:

  1. Calls htmlAudio.init() to create the underlying HTMLAudioElement
  2. Creates a secondary muted <audio> element for pre-loading the next track
  3. Attaches all event listeners via AbortController (automatically cleaned up on destroy)
  4. Restores the last playback position, volume, and track from localStorage

Reactive $effect Sync

Eight $effect runes keep the HTMLAudioElement in sync with audioStore:

Effect Trigger Action
Track sync tracks prop changes Updates audioStore.queue if tracks differ
Track change audioStore.currentTrack changes Loads and plays new track
Seek audioStore.currentTime user update Seeks HTMLAudioElement
Volume audioStore.volume changes Sets audio.volume
Mute audioStore.isMuted changes Sets audio.muted
Playback rate audioStore.playbackRate changes Sets audio.playbackRate
Queue reset audioStore.queue becomes empty Clears preload audio src
Persistence Any tracked state changes Saves to localStorage

Error Handling

AudioProvider classifies MediaError codes and decides if an error is recoverable:

Error code Message Recoverable
MEDIA_ERR_ABORTED (1) Playback cancelled Yes
MEDIA_ERR_NETWORK (2) Network error Yes
MEDIA_ERR_DECODE (3) Audio file decoding error No
MEDIA_ERR_SRC_NOT_SUPPORTED (4) File/network loading error Yes

For recoverable errors, it retries up to 3 times with exponential backoff (1s → 2s → 4s). If all retries fail, audioStore.isError is set to true with an error message.

Next Track Preloading

When playback starts, AudioProvider preloads the next track in a secondary muted <audio> element. This minimizes buffering delay when skipping or when the current track ends. It respects shuffle and repeat mode when calculating which track to preload.

State Persistence

audioStore automatically saves to localStorage under the key audio:ui:store whenever any of these fields change:

  • Current track and queue
  • Volume and mute
  • Playback rate
  • Repeat mode and shuffle
  • Current playback position
  • Queue insert mode and current index

On the next page load, AudioProvider restores these values and seeks to the last known position.

Notes

  • Only one instance — mount AudioProvider once at the top of your app. Multiple instances will conflict because they share the same htmlAudio singleton.

  • tracks prop is additive — if audioStore.queue already has tracks (e.g. restored from localStorage), the tracks prop won't overwrite them unless they differ by length or id. This prevents overwriting the restored queue on page reload.

  • No playback on restore — AudioProvider restores the last position and loads the track, but does not auto-play. The user must press play.

  • Live stream detection — live streams are detected via audio.duration === Infinity or NaN. On a live stream end event (stream drops), audioStore.isError is set with the message "Live stream connection lost".

Audio Player Audio Queue
Built by ddtamn. The source code is available on Github