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

InstallationImportCore ConceptsThe audioStore SingletonArchitectureStore StateTypesUtility FunctionscalculateNextIndexcalculatePreviousIndexcanUseDOM()ActionsExamplesBasic UsageQueue ManagementOutside of ComponentsPersistenceRelatedNotes

Audio Store

Previous Next

A global, reactive Svelte 5 store for managing audio playback state and queue.


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.

import { htmlAudio, type Track } from "$lib/html-audio.js";

export type RepeatMode = "none" | "one" | "all";
export type InsertMode = "first" | "last" | "after";

// ─── Helper functions ────────────────────────────────────────────────────────

export function canUseDOM(): boolean {
  return typeof window !== "undefined" && !!window.document?.createElement;
}

type QueueNavigationParams = {
  queue: Track[];
  currentQueueIndex: number;
  shuffleEnabled: boolean;
  repeatMode: RepeatMode;
};

function getRandomShuffleIndex(queueLength: number, currentIndex: number): number {
  if (queueLength === 1) return 0;
  let idx: number;
  do {
    idx = Math.floor(Math.random() * queueLength);
  } while (idx === currentIndex);
  return idx;
}

function calculateQueueIndex(params: QueueNavigationParams & { direction: 1 | -1 }): number {
  const { queue, currentQueueIndex, shuffleEnabled, repeatMode, direction } = params;
  if (queue.length === 0) return -1;
  if (shuffleEnabled) {
    if (queue.length === 1) return repeatMode === "none" ? -1 : 0;
    return getRandomShuffleIndex(queue.length, currentQueueIndex);
  }
  const next = currentQueueIndex + direction;
  if (next >= queue.length) return repeatMode === "all" ? 0 : -1;
  if (next < 0) return repeatMode === "all" ? queue.length - 1 : -1;
  return next;
}

export function calculateNextIndex(params: QueueNavigationParams): number {
  return calculateQueueIndex({ ...params, direction: 1 });
}

export function calculatePreviousIndex(params: QueueNavigationParams): number {
  return calculateQueueIndex({ ...params, direction: -1 });
}

// ─── Store ───────────────────────────────────────────────────────────────────

const STORAGE_KEY = "audio:ui:store";

class AudioStore {
  // ── Reactive state ──────────────────────────────────────────────────────
  currentTrack: Track | null = $state(null);
  queue: Track[] = $state([]);
  isPlaying = $state(false);
  isLoading = $state(false);
  isBuffering = $state(false);
  volume = $state(1);
  isMuted = $state(false);
  playbackRate = $state(1);
  repeatMode: RepeatMode = $state("none");
  shuffleEnabled = $state(false);
  currentTime = $state(0);
  duration = $state(0);
  progress = $state(0);
  bufferedTime = $state(0);
  insertMode: InsertMode = $state("last");
  isError = $state(false);
  errorMessage: string | null = $state(null);
  currentQueueIndex = $state(-1);

  constructor() {
    if (canUseDOM()) this.loadFromStorage();
  }

  // ── Persistence ─────────────────────────────────────────────────────────

  loadFromStorage(): void {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return;
      const d = JSON.parse(raw);
      if (d.currentTrack !== undefined) this.currentTrack = d.currentTrack;
      if (d.queue !== undefined) this.queue = d.queue;
      if (d.volume !== undefined) this.volume = d.volume;
      if (d.isMuted !== undefined) this.isMuted = d.isMuted;
      if (d.playbackRate !== undefined) this.playbackRate = d.playbackRate;
      if (d.repeatMode !== undefined) this.repeatMode = d.repeatMode;
      if (d.shuffleEnabled !== undefined) this.shuffleEnabled = d.shuffleEnabled;
      if (d.currentTime !== undefined) this.currentTime = d.currentTime;
      if (d.insertMode !== undefined) this.insertMode = d.insertMode;
      if (d.currentQueueIndex !== undefined) this.currentQueueIndex = d.currentQueueIndex;
    } catch {
      /* ignore */
    }
  }

  saveToStorage(): void {
    if (!canUseDOM()) return;
    try {
      localStorage.setItem(
        STORAGE_KEY,
        JSON.stringify({
          currentTrack: this.currentTrack,
          queue: this.queue,
          volume: this.volume,
          isMuted: this.isMuted,
          playbackRate: this.playbackRate,
          repeatMode: this.repeatMode,
          shuffleEnabled: this.shuffleEnabled,
          currentTime: this.currentTime,
          insertMode: this.insertMode,
          currentQueueIndex: this.currentQueueIndex,
        })
      );
    } catch {
      /* ignore */
    }
  }

  // ── Playback actions ────────────────────────────────────────────────────

  play(): void {
    if (this.isLoading) return;
    this.isPlaying = true;
    htmlAudio.play().catch(() => {});
  }

  pause(): void {
    this.isPlaying = false;
    htmlAudio.pause();
  }

  togglePlay(): void {
    if (this.isLoading) return;
    if (this.isPlaying) {
      this.pause();
    } else {
      this.play();
    }
  }

  seek(time: number): void {
    const valid = this.duration > 0 ? Math.max(0, Math.min(time, this.duration)) : time;
    this.currentTime = valid;
    this.progress = this.duration > 0 ? (valid / this.duration) * 100 : 0;
  }

  next(): void {
    const idx = calculateNextIndex({
      queue: this.queue,
      currentQueueIndex: this.currentQueueIndex,
      shuffleEnabled: this.shuffleEnabled,
      repeatMode: this.repeatMode,
    });
    const track = this.queue[idx];
    if (idx === -1 || !track) {
      this.isLoading = false;
      this.isPlaying = false;
      this.isBuffering = false;
      return;
    }
    this.loadAndPlayTrack(track, idx);
  }

  previous(): void {
    if (this.currentTime > 3 && !this.shuffleEnabled) {
      this.currentTime = 0;
      this.progress = 0;
      return;
    }
    const idx = calculatePreviousIndex({
      queue: this.queue,
      currentQueueIndex: this.currentQueueIndex,
      shuffleEnabled: this.shuffleEnabled,
      repeatMode: this.repeatMode,
    });
    const track = this.queue[idx];
    if (idx === -1 || !track) {
      this.isLoading = false;
      this.isPlaying = false;
      this.isBuffering = false;
      return;
    }
    this.loadAndPlayTrack(track, idx);
  }

  setQueueAndPlay(songs: Track[], startIndex: number): void {
    const target = songs[startIndex];
    if (!target) {
      this.clearQueue();
      this.isPlaying = false;
      this.isLoading = false;
      this.currentTrack = null;
      this.currentQueueIndex = -1;
      return;
    }
    this.setQueue(songs, startIndex);
    this.loadAndPlayTrack(target, startIndex);
  }

  handleTrackEnd(): void {
    this.next();
  }

  // ── Queue actions ───────────────────────────────────────────────────────

  setQueue(tracks: Track[], startIndex = 0): void {
    const current = tracks[startIndex] ?? null;
    this.queue = tracks;
    this.currentQueueIndex = current ? startIndex : -1;
    this.currentTrack = current;
  }

  getCurrentQueueIndex(): number {
    return this.currentQueueIndex;
  }

  addToQueue(track: Track, mode: InsertMode = "last"): void {
    if (!this.currentTrack) {
      this.currentTrack = track;
      this.currentQueueIndex = 0;
      this.queue = [track];
      return;
    }
    switch (mode) {
      case "first":
        this.queue = [track, ...this.queue];
        this.currentQueueIndex++;
        break;
      case "after":
        this.queue = [
          ...this.queue.slice(0, this.currentQueueIndex + 1),
          track,
          ...this.queue.slice(this.currentQueueIndex + 1),
        ];
        break;
      default:
        this.queue = [...this.queue, track];
    }
  }

  removeFromQueue(trackId: string): void {
    const idx = this.queue.findIndex((s) => s.id === trackId);
    if (idx === -1) return;
    this.queue = this.queue.filter((s) => s.id !== trackId);
    if (idx < this.currentQueueIndex) this.currentQueueIndex--;
  }

  clearQueue(): void {
    this.queue = [];
  }

  moveInQueue(fromIndex: number, toIndex: number): void {
    const q = [...this.queue];
    const [item] = q.splice(fromIndex, 1);
    if (!item) return;
    q.splice(toIndex, 0, item);
    this.queue = q;
  }

  addTracksToEndOfQueue(tracks: Track[]): void {
    if (!tracks?.length) return;
    const ids = new Set(this.queue.map((s) => s.id));
    const newTracks = tracks.filter((t) => !ids.has(t.id));
    if (newTracks.length) this.queue = [...this.queue, ...newTracks];
  }

  // ── Control actions ─────────────────────────────────────────────────────

  setVolume(params: { volume: number }): void {
    this.volume = params.volume;
    this.isMuted = params.volume === 0;
  }

  toggleMute(): void {
    this.isMuted = !this.isMuted;
  }

  setPlaybackRate(rate: number): void {
    if (this.duration && htmlAudio.isLive(this.duration)) return;
    this.playbackRate = Math.max(0.25, Math.min(2, rate));
  }

  changeRepeatMode(): void {
    const modes: RepeatMode[] = ["none", "one", "all"];
    const i = modes.indexOf(this.repeatMode);
    this.repeatMode = modes[(i + 1) % modes.length] as RepeatMode;
  }

  setRepeatMode(mode: RepeatMode): void {
    this.repeatMode = mode;
  }
  setInsertMode(mode: InsertMode): void {
    this.insertMode = mode;
  }

  shuffle(): void {
    if (!this.queue.length || this.queue.length < 2 || !this.currentTrack) return;
    const rest = this.queue.filter((_, i) => i !== this.currentQueueIndex);
    const shuffled = rest.sort(() => Math.random() - 0.5);
    this.queue = [this.currentTrack, ...shuffled];
    this.currentQueueIndex = 0;
    this.shuffleEnabled = true;
  }

  unshuffle(): void {
    this.shuffleEnabled = false;
  }

  // ── State actions ───────────────────────────────────────────────────────

  setCurrentTrack(track: Track | null): void {
    if (!track) {
      this.currentTrack = null;
      this.currentQueueIndex = -1;
      this.isPlaying = false;
      this.currentTime = 0;
      this.duration = 0;
      this.queue = [];
      this.isLoading = false;
      this.isError = false;
      this.errorMessage = null;
      return;
    }
    if (this.currentTrack?.id === track.id) return;
    this.currentTrack = track;
    this.queue = [track];
    this.currentQueueIndex = 0;
    this.isLoading = true;
    this.isPlaying = false;
    this.currentTime = 0;
    this.duration = 0;
    this.isError = false;
    this.errorMessage = null;
    this.loadAndPlayTrack(track, 0);
  }

  /** Called by AudioProvider to sync audio element time into state. */
  syncTime(currentTime: number, duration: number): void {
    this.currentTime = currentTime;
    this.duration = duration;
    this.progress = duration > 0 ? (currentTime / duration) * 100 : 0;
  }

  setError(message: string | null): void {
    this.isError = !!message;
    this.errorMessage = message;
    this.isLoading = false;
    this.isPlaying = false;
  }

  /**
   * Sets state to signal the provider to load and play a track.
   * The actual audio loading is handled by AudioProvider via $effect.
   */
  loadAndPlayTrack(track: Track, queueIndex: number): void {
    const isLiveStream =
      track.live === true ||
      (track.duration !== undefined && htmlAudio.isLive(track.duration));

    this.currentTrack = track;
    this.currentQueueIndex = queueIndex;
    this.isError = false;
    this.errorMessage = null;

    if (isLiveStream && this.playbackRate !== 1) this.playbackRate = 1;

    // Signal provider: set isPlaying=true to trigger load+play effect
    this.isLoading = false;
    this.isBuffering = false;
    this.isPlaying = true;
  }
}

export const audioStore = new AudioStore();

Import

Import the global store and helper types directly into your Svelte components:

<script lang="ts">
  import {
    audioStore,
    calculateNextIndex,
    calculatePreviousIndex,
    canUseDOM,
    type RepeatMode,
    type InsertMode,
  } from "$lib/audio-store.svelte";
</script>

Core Concepts

The audioStore Singleton

We leverage Svelte 5's powerful $state runes to create a highly reactive, class-based global store. Instead of messy subscriptions or cumbersome contexts, you just import audioStore and read its properties anywhere. Svelte handles the magic.

Performance Best Practices: Because `audioStore` uses runes, accessing its properties in your markup or inside `$derived` / `$effect` blocks automatically establishes granular reactivity. Only the parts of your UI that depend on changed state will re-render!
<script lang="ts">
  import { audioStore } from "$lib/audio-store.svelte";
</script>
 
<p>
  {audioStore.currentTrack?.title} ({audioStore.duration}s) — {audioStore.isPlaying
    ? "▶"
    : "⏸"}
</p>

Architecture

The AudioStore focuses purely on state management. It doesn't touch the <audio> element directly. Instead, the AudioProvider component listens to the store and orchestrates the browser's Audio API.

Store State

The store exposes properties organized by concern. All of these are deeply reactive ($state).

Category Property Type Description
Playback isPlaying boolean Currently playing
isLoading boolean Loading track
isBuffering boolean Buffering audio
isError boolean Error state
errorMessage string | null Error details
Current Track currentTrack Track | null Active track object
currentTime number Playback position (seconds)
duration number Track length
progress number Normalized progress 0–100
bufferedTime number Buffered amount
Queue queue Track[] Array of tracks
currentQueueIndex number Active track index
Controls volume number Volume 0–1
isMuted boolean Mute state
playbackRate number Playback speed (0.25–2)
repeatMode "none" | "one" | "all" Repeat mode
shuffleEnabled boolean Shuffle state
insertMode "first" | "last" | "after" Insertion position for queue

Types

Type Values Description
RepeatMode "none" | "one" | "all" Repeat playback mode
InsertMode "first" | "last" | "after" Where new tracks are added to the queue

Utility Functions

calculateNextIndex

Calculate the next track index based on playback mode, queue length, and shuffle state.

const nextIndex = calculateNextIndex({
  queue: audioStore.queue,
  currentQueueIndex: audioStore.currentQueueIndex,
  shuffleEnabled: audioStore.shuffleEnabled,
  repeatMode: audioStore.repeatMode,
});
// Returns: number (track index or -1 if none)

calculatePreviousIndex

Calculate the previous track index based on playback mode.

const prevIndex = calculatePreviousIndex({
  queue: audioStore.queue,
  currentQueueIndex: audioStore.currentQueueIndex,
  shuffleEnabled: audioStore.shuffleEnabled,
  repeatMode: audioStore.repeatMode,
});
// Returns: number (track index or -1 if none)

canUseDOM()

Check if code runs in a DOM environment (safe for SSR).

if (canUseDOM()) {
  // Safe to access window or localStorage
}

Actions

Access actions directly on the audioStore instance.

Category Action Signature Description
Playback play () => void Signifies playback intent
pause () => void Pause playback
togglePlay () => void Toggle play/pause state
seek (time: number) => void Seek to position (seconds)
Navigation next () => void Play next track
previous () => void Play previous track (or restart based on time)
setCurrentTrack (track: Track | null) => void Load and play specific track
setQueueAndPlay (tracks: Track[], startIndex: number) => void Set queue and play from index
Queue addToQueue (track: Track, mode?: InsertMode) => void Add track to queue (supports "first", "last", "after")
removeFromQueue (trackId: string) => void Remove track from queue
moveInQueue (fromIndex: number, toIndex: number) => void Move track in queue
setQueue (tracks: Track[], startIndex?: number) => void Replace entire queue
clearQueue () => void Clear all tracks from queue
Volume setVolume ({ volume: number }) => void Set volume (0-1)
toggleMute () => void Toggle mute state
Modes setPlaybackRate (rate: number) => void Adjust playback speed
changeRepeatMode () => void Cycle repeat mode (none → one → all → none)
setRepeatMode (mode: RepeatMode) => void Set repeat mode
shuffle () => void Randomize queue order
unshuffle () => void Restore original queue order
setInsertMode (mode: InsertMode) => void Set insert mode
Error setError (message: string | null) => void Set or clear error state

Examples

Basic Usage

Building a custom minimal player is insanely easy. Just wire up the store.

<script lang="ts">
  import { audioStore } from "$lib/audio-store.svelte";
 
  function formatDuration(seconds: number) {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, "0")}`;
  }
</script>
 
<div class="rounded-md border p-4">
  <p class="font-bold">Track: {audioStore.currentTrack?.title ?? "None"}</p>
  <p class="text-sm text-gray-500">
    Time: {formatDuration(audioStore.currentTime)} / {formatDuration(
      audioStore.duration
    )}
  </p>
  <p class="mb-2">Status: {audioStore.isPlaying ? "▶ Playing" : "⏸ Paused"}</p>
 
  <div class="flex gap-2">
    <button onclick={() => audioStore.previous()}>◀ Prev</button>
    <button onclick={() => audioStore.togglePlay()}>
      {audioStore.isPlaying ? "⏸" : "▶"}
    </button>
    <button onclick={() => audioStore.next()}>Next ▶</button>
  </div>
</div>

Queue Management

Easily manipulate the queue with just a few method calls.

<script lang="ts">
  import { audioStore } from "$lib/audio-store.svelte";
 
  const newTrack = { id: "123", title: "Indie Banger", src: "/music.mp3" };
</script>
 
<div>
  <div class="mb-4 flex gap-2">
    <button onclick={() => audioStore.addToQueue(newTrack, "last")}
      >Add Track</button
    >
    <button onclick={() => audioStore.clearQueue()}>Clear</button>
  </div>
  <div class="space-y-1">
    {#each audioStore.queue as track (track.id)}
      <div class="flex justify-between border p-2">
        <span>{track.title}</span>
        <button onclick={() => audioStore.removeFromQueue(track.id)}
          >Remove</button
        >
      </div>
    {/each}
  </div>
</div>

Outside of Components

Need to kick off music from a vanilla .ts file or a SvelteKit load function? Since audioStore is a class singleton, it works anywhere.

import { audioStore } from "$lib/audio-store.svelte";
 
// Read state
console.log(audioStore.isPlaying);
 
// Call actions
audioStore.play();
 
// Update volume
audioStore.setVolume({ volume: 0.5 });

Persistence

We hate it when a refresh kills the vibe. The store automatically hydrates and syncs a subset of its state to localStorage on the fly.

Category Properties
Playback currentTrack, currentTime, currentQueueIndex, playbackRate
Queue queue
Settings volume, isMuted, repeatMode, shuffleEnabled, insertMode

Storage Key: audio:ui:store

The user can resume their queue seamlessly after a page refresh, as if they never left.

Related

  • Audio Library — Core HTMLAudio integration
  • Audio Provider — Handles the HTML <audio> bindings.
  • Audio Player — Drop-in UI components.
  • Audio Queue — Queue UI management.

Notes

Best Practices:
  • audioStore focuses solely on state. The AudioProvider component handles the actual loading and syncing of the <audio> element.
  • Ensure the AudioProvider wraps your app, or at least the part where playback happens, for the state to translate to real sound.
  • Async events (buffering, track end) are driven by the AudioProvider which calls methods like handleTrackEnd() or sets syncTime() on the audioStore.
  • The playbackRate automatically resets to 1 when loading live streams to prevent awful chipmunk audio.
XY Pad HTML Audio
Built by ddtamn. The source code is available on Github